-
# retain_original_contact_name: false / true
-
# In case of setUser we want to update the name of the identified contact,
-
# which is the default behaviour
-
#
-
# But, In case of contact merge during prechat form contact update.
-
# We don't want to update the name of the identified original contact.
-
-
class ContactIdentifyAction
-
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
-
-
def perform
-
@attributes_to_update = [:identifier, :name, :email, :phone_number]
-
-
ActiveRecord::Base.transaction do
-
merge_if_existing_identified_contact
-
merge_if_existing_email_contact
-
merge_if_existing_phone_number_contact
-
update_contact
-
end
-
@contact
-
end
-
-
private
-
-
def account
-
@account ||= @contact.account
-
end
-
-
def merge_if_existing_identified_contact
-
return unless merge_contacts?(existing_identified_contact, :identifier)
-
-
process_contact_merge(existing_identified_contact)
-
end
-
-
def merge_if_existing_email_contact
-
return unless merge_contacts?(existing_email_contact, :email)
-
-
process_contact_merge(existing_email_contact)
-
end
-
-
def merge_if_existing_phone_number_contact
-
return unless merge_contacts?(existing_phone_number_contact, :phone_number)
-
return unless mergable_phone_contact?
-
-
process_contact_merge(existing_phone_number_contact)
-
end
-
-
def process_contact_merge(mergee_contact)
-
@contact = merge_contact(mergee_contact, @contact)
-
@attributes_to_update.delete(:name) if retain_original_contact_name
-
end
-
-
def existing_identified_contact
-
return if params[:identifier].blank?
-
-
@existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier])
-
end
-
-
def existing_email_contact
-
return if params[:email].blank?
-
-
@existing_email_contact ||= account.contacts.from_email(params[:email])
-
end
-
-
def existing_phone_number_contact
-
return if params[:phone_number].blank?
-
-
@existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
-
end
-
-
def merge_contacts?(existing_contact, key)
-
return if existing_contact.blank?
-
-
return true if params[:identifier].blank?
-
-
# we want to prevent merging contacts with different identifiers
-
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
-
# we will remove attribute from update list
-
@attributes_to_update.delete(key)
-
return false
-
end
-
-
true
-
end
-
-
# case: contact 1: email: 1@test.com, phone: 123456789
-
# params: email: 2@test.com, phone: 123456789
-
# we don't want to overwrite 1@test.com since email parameter takes higer priority
-
def mergable_phone_contact?
-
return true if params[:email].blank?
-
-
if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email]
-
@attributes_to_update.delete(:phone_number)
-
return false
-
end
-
true
-
end
-
-
def update_contact
-
@contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v|
-
v.blank?
-
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })
-
# blank identifier or email will throw unique index error
-
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
-
@contact.discard_invalid_attrs if discard_invalid_attrs
-
@contact.save!
-
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
-
end
-
-
def merge_contact(base_contact, merge_contact)
-
return base_contact if base_contact.id == merge_contact.id
-
-
ContactMergeAction.new(
-
account: account,
-
base_contact: base_contact,
-
mergee_contact: merge_contact
-
).perform
-
end
-
-
def custom_attributes
-
return @contact.custom_attributes if params[:custom_attributes].blank?
-
-
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
-
end
-
-
def additional_attributes
-
return @contact.additional_attributes if params[:additional_attributes].blank?
-
-
(@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
-
end
-
end
-
class ContactMergeAction
-
include Events::Types
-
pattr_initialize [:account!, :base_contact!, :mergee_contact!]
-
-
def perform
-
# This case happens when an agent updates a contact email in dashboard,
-
# while the contact also update his email via email collect box
-
return @base_contact if base_contact.id == mergee_contact.id
-
-
ActiveRecord::Base.transaction do
-
validate_contacts
-
merge_conversations
-
merge_messages
-
merge_contact_inboxes
-
merge_contact_notes
-
merge_and_remove_mergee_contact
-
end
-
@base_contact
-
end
-
-
private
-
-
def validate_contacts
-
return if belongs_to_account?(@base_contact) && belongs_to_account?(@mergee_contact)
-
-
raise StandardError, 'contact does not belong to the account'
-
end
-
-
def belongs_to_account?(contact)
-
@account.id == contact.account_id
-
end
-
-
def merge_conversations
-
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
-
end
-
-
def merge_contact_notes
-
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
-
end
-
-
def merge_messages
-
Message.where(sender: @mergee_contact).update(sender: @base_contact)
-
end
-
-
def merge_contact_inboxes
-
ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
-
end
-
-
def merge_and_remove_mergee_contact
-
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
-
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
-
mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank
-
-
# attributes in base contact are given preference
-
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
-
-
@mergee_contact.reload.destroy!
-
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
-
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
-
@base_contact.update!(merged_attributes)
-
end
-
end
-
# frozen_string_literal: true
-
-
class AccountBuilder
-
include CustomExceptions::Account
-
pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
-
-
def perform
-
if @user.nil?
-
validate_email
-
validate_user
-
end
-
ActiveRecord::Base.transaction do
-
@account = create_account
-
@user = create_and_link_user
-
end
-
[@user, @account]
-
rescue StandardError => e
-
Rails.logger.debug e.inspect
-
raise e
-
end
-
-
private
-
-
def user_full_name
-
# the empty string ensures that not-null constraint is not violated
-
@user_full_name || ''
-
end
-
-
def account_name
-
# the empty string ensures that not-null constraint is not violated
-
@account_name || ''
-
end
-
-
def validate_email
-
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
-
-
address = ValidEmail2::Address.new(@email)
-
if address.valid? && !address.disposable?
-
true
-
else
-
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
-
end
-
end
-
-
def validate_user
-
if User.exists?(email: @email)
-
raise UserExists.new(email: @email)
-
else
-
true
-
end
-
end
-
-
def create_account
-
@account = Account.create!(name: account_name, locale: I18n.locale)
-
Current.account = @account
-
end
-
-
def create_and_link_user
-
if @user.present? || create_user
-
link_user_to_account(@user, @account)
-
@user
-
else
-
raise UserErrors.new(errors: @user.errors)
-
end
-
end
-
-
def link_user_to_account(user, account)
-
AccountUser.create!(
-
account_id: account.id,
-
user_id: user.id,
-
role: AccountUser.roles['administrator']
-
)
-
end
-
-
def create_user
-
@user = User.new(email: @email,
-
password: user_password,
-
password_confirmation: user_password,
-
name: user_full_name)
-
@user.type = 'SuperAdmin' if @super_admin
-
@user.confirm if @confirmed
-
@user.save!
-
end
-
-
def domain_blocked?
-
domain = @email.split('@').last
-
-
blocked_domains.each do |blocked_domain|
-
return true if domain.match?(blocked_domain)
-
end
-
-
false
-
end
-
-
def blocked_domains
-
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
-
return [] if domains.blank?
-
-
domains.split("\n").map(&:strip)
-
end
-
end
-
# The AgentBuilder class is responsible for creating a new agent.
-
# It initializes with necessary attributes and provides a perform method
-
# to create a user and account user in a transaction.
-
class AgentBuilder
-
# Initializes an AgentBuilder with necessary attributes.
-
# @param email [String] the email of the user.
-
# @param name [String] the name of the user.
-
# @param role [String] the role of the user, defaults to 'agent' if not provided.
-
# @param inviter [User] the user who is inviting the agent (Current.user in most cases).
-
# @param availability [String] the availability status of the user, defaults to 'offline' if not provided.
-
# @param auto_offline [Boolean] the auto offline status of the user.
-
pattr_initialize [:email, { name: '' }, :inviter, :account, { role: :agent }, { availability: :offline }, { auto_offline: false }]
-
-
# Creates a user and account user in a transaction.
-
# @return [User] the created user.
-
def perform
-
ActiveRecord::Base.transaction do
-
@user = find_or_create_user
-
create_account_user
-
end
-
@user
-
end
-
-
private
-
-
# Finds a user by email or creates a new one with a temporary password.
-
# @return [User] the found or created user.
-
def find_or_create_user
-
user = User.from_email(email)
-
return user if user
-
-
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
-
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
-
end
-
-
# Checks if the user needs confirmation.
-
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
-
def user_needs_confirmation?
-
@user.persisted? && !@user.confirmed?
-
end
-
-
# Creates an account user linking the user to the current account.
-
def create_account_user
-
AccountUser.create!({
-
account_id: account.id,
-
user_id: @user.id,
-
inviter_id: inviter.id
-
}.merge({
-
role: role,
-
availability: availability,
-
auto_offline: auto_offline
-
}.compact))
-
end
-
end
-
class Campaigns::CampaignConversationBuilder
-
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
-
-
def perform
-
@contact_inbox = ContactInbox.find(@contact_inbox_id)
-
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
-
-
ActiveRecord::Base.transaction do
-
@contact_inbox.lock!
-
-
# We won't send campaigns if a conversation is already present
-
raise 'Conversation already present' if @contact_inbox.reload.conversations.present?
-
-
@conversation = ::Conversation.create!(conversation_params)
-
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
-
end
-
@conversation
-
rescue StandardError => e
-
Rails.logger.info(e.message)
-
nil
-
end
-
-
private
-
-
def message_params
-
ActionController::Parameters.new({
-
content: @campaign.message,
-
campaign_id: @campaign.id
-
})
-
end
-
-
def conversation_params
-
{
-
account_id: @campaign.account_id,
-
inbox_id: @contact_inbox.inbox_id,
-
contact_id: @contact_inbox.contact_id,
-
contact_inbox_id: @contact_inbox.id,
-
campaign_id: @campaign.id,
-
additional_attributes: conversation_additional_attributes,
-
custom_attributes: custom_attributes || {}
-
}
-
end
-
end
-
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
-
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
-
-
class ContactInboxBuilder
-
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
-
-
def perform
-
@source_id ||= generate_source_id
-
create_contact_inbox if source_id.present?
-
end
-
-
private
-
-
def generate_source_id
-
case @inbox.channel_type
-
when 'Channel::TwilioSms'
-
twilio_source_id
-
when 'Channel::Whatsapp'
-
wa_source_id
-
when 'Channel::Email'
-
email_source_id
-
when 'Channel::Sms'
-
phone_source_id
-
when 'Channel::Api', 'Channel::WebWidget'
-
SecureRandom.uuid
-
else
-
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
-
end
-
end
-
-
def email_source_id
-
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
-
-
@contact.email
-
end
-
-
def phone_source_id
-
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
-
-
@contact.phone_number
-
end
-
-
def wa_source_id
-
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
-
-
# whatsapp doesn't want the + in e164 format
-
@contact.phone_number.delete('+').to_s
-
end
-
-
def twilio_source_id
-
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
-
-
case @inbox.channel.medium
-
when 'sms'
-
@contact.phone_number
-
when 'whatsapp'
-
"whatsapp:#{@contact.phone_number}"
-
end
-
end
-
-
def create_contact_inbox
-
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
-
contact_id: @contact.id,
-
inbox_id: @inbox.id,
-
source_id: @source_id
-
)
-
rescue ActiveRecord::RecordNotUnique
-
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
-
update_old_contact_inbox
-
retry
-
end
-
-
def update_old_contact_inbox
-
# The race condition occurs when there’s a contact inbox with the
-
# same source ID but linked to a different contact. This can happen
-
# if the agent updates the contact’s email or phone number, or
-
# if the contact is merged with another.
-
#
-
# We update the old contact inbox source_id to a random value to
-
# avoid disrupting the current flow. However, the root cause of
-
# this issue is a flaw in the contact inbox model design.
-
# Contact inbox is essentially tracking a session and is not
-
# needed for non-live chat channels.
-
raise ActiveRecord::RecordNotUnique unless allowed_channels?
-
-
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
-
return if contact_inbox.blank?
-
-
contact_inbox.update!(source_id: new_source_id)
-
end
-
-
def new_source_id
-
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
-
"whatsapp:#{@source_id}#{rand(100)}"
-
else
-
"#{rand(10)}#{@source_id}"
-
end
-
end
-
-
def allowed_channels?
-
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
-
end
-
end
-
# This Builder will create a contact and contact inbox with specified attributes.
-
# If an existing identified contact exisits, it will be returned.
-
# for contact inbox logic it uses the contact inbox builder
-
-
class ContactInboxWithContactBuilder
-
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
-
-
def perform
-
find_or_create_contact_and_contact_inbox
-
# in case of race conditions where contact is created by another thread
-
# we will try to find the contact and create a contact inbox
-
rescue ActiveRecord::RecordNotUnique
-
find_or_create_contact_and_contact_inbox
-
end
-
-
def find_or_create_contact_and_contact_inbox
-
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
-
return @contact_inbox if @contact_inbox
-
-
ActiveRecord::Base.transaction(requires_new: true) do
-
build_contact_with_contact_inbox
-
end
-
update_contact_avatar(@contact) unless @contact.avatar.attached?
-
@contact_inbox
-
end
-
-
private
-
-
def build_contact_with_contact_inbox
-
@contact = find_contact || create_contact
-
@contact_inbox = create_contact_inbox
-
end
-
-
def account
-
@account ||= inbox.account
-
end
-
-
def create_contact_inbox
-
ContactInboxBuilder.new(
-
contact: @contact,
-
inbox: @inbox,
-
source_id: @source_id,
-
hmac_verified: hmac_verified
-
).perform
-
end
-
-
def update_contact_avatar(contact)
-
::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
-
end
-
-
def create_contact
-
account.contacts.create!(
-
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
-
phone_number: contact_attributes[:phone_number],
-
email: contact_attributes[:email],
-
identifier: contact_attributes[:identifier],
-
additional_attributes: contact_attributes[:additional_attributes],
-
custom_attributes: contact_attributes[:custom_attributes]
-
)
-
end
-
-
def find_contact
-
contact = find_contact_by_identifier(contact_attributes[:identifier])
-
contact ||= find_contact_by_email(contact_attributes[:email])
-
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
-
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
-
-
contact
-
end
-
-
def instagram_channel?
-
inbox.channel_type == 'Channel::Instagram'
-
end
-
-
# There might be existing contact_inboxes created through Channel::FacebookPage
-
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
-
# while still reusing contacts if found in Facebook channels so that we can create
-
# new conversations with the same contact.
-
def find_contact_by_instagram_source_id(instagram_id)
-
return if instagram_id.blank?
-
-
existing_contact_inbox = ContactInbox.joins(:inbox)
-
.where(source_id: instagram_id)
-
.where(
-
'inboxes.channel_type = ? AND inboxes.account_id = ?',
-
'Channel::FacebookPage',
-
account.id
-
).first
-
-
existing_contact_inbox&.contact
-
end
-
-
def find_contact_by_identifier(identifier)
-
return if identifier.blank?
-
-
account.contacts.find_by(identifier: identifier)
-
end
-
-
def find_contact_by_email(email)
-
return if email.blank?
-
-
account.contacts.from_email(email)
-
end
-
-
def find_contact_by_phone_number(phone_number)
-
return if phone_number.blank?
-
-
account.contacts.find_by(phone_number: phone_number)
-
end
-
end
-
class ConversationBuilder
-
pattr_initialize [:params!, :contact_inbox!]
-
-
def perform
-
look_up_exising_conversation || create_new_conversation
-
end
-
-
private
-
-
def look_up_exising_conversation
-
return unless @contact_inbox.inbox.lock_to_single_conversation?
-
-
@contact_inbox.conversations.last
-
end
-
-
def create_new_conversation
-
::Conversation.create!(conversation_params)
-
end
-
-
def conversation_params
-
additional_attributes = params[:additional_attributes]&.permit! || {}
-
custom_attributes = params[:custom_attributes]&.permit! || {}
-
status = params[:status].present? ? { status: params[:status] } : {}
-
-
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
-
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
-
# status = { status: 'pending' } if status[:status] == 'bot'
-
{
-
account_id: @contact_inbox.inbox.account_id,
-
inbox_id: @contact_inbox.inbox_id,
-
contact_id: @contact_inbox.contact_id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: additional_attributes,
-
custom_attributes: custom_attributes,
-
snoozed_until: params[:snoozed_until],
-
assignee_id: params[:assignee_id],
-
team_id: params[:team_id]
-
}.merge(status)
-
end
-
end
-
class CsatSurveys::ResponseBuilder
-
pattr_initialize [:message]
-
-
def perform
-
raise 'Invalid Message' unless message.input_csat?
-
-
conversation = message.conversation
-
rating = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'rating')
-
feedback_message = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'feedback_message')
-
-
return if rating.blank?
-
-
process_csat_response(conversation, rating, feedback_message)
-
end
-
-
private
-
-
def process_csat_response(conversation, rating, feedback_message)
-
csat_survey_response = message.csat_survey_response || CsatSurveyResponse.new(
-
message_id: message.id, account_id: message.account_id, conversation_id: message.conversation_id,
-
contact_id: conversation.contact_id, assigned_agent: conversation.assignee
-
)
-
csat_survey_response.rating = rating
-
csat_survey_response.feedback_message = feedback_message
-
csat_survey_response.save!
-
csat_survey_response
-
end
-
end
-
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
-
# Assumptions
-
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
-
# based on this we are showing "not sent from chatwoot" message in frontend
-
# Hence there is no need to set user_id in message for outgoing echo messages.
-
-
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
-
attr_reader :response
-
-
def initialize(response, inbox, outgoing_echo: false)
-
super()
-
@response = response
-
@inbox = inbox
-
@outgoing_echo = outgoing_echo
-
@sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id)
-
@message_type = (@outgoing_echo ? :outgoing : :incoming)
-
@attachments = (@response.attachments || [])
-
end
-
-
def perform
-
# This channel might require reauthorization, may be owner might have changed the fb password
-
return if @inbox.channel.reauthorization_required?
-
-
ActiveRecord::Base.transaction do
-
build_contact_inbox
-
build_message
-
end
-
rescue Koala::Facebook::AuthenticationError => e
-
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
-
Rails.logger.error e
-
@inbox.channel.authorization_error!
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
-
true
-
end
-
-
private
-
-
def build_contact_inbox
-
@contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: @sender_id,
-
inbox: @inbox,
-
contact_attributes: contact_params
-
).perform
-
end
-
-
def build_message
-
@message = conversation.messages.create!(message_params)
-
-
@attachments.each do |attachment|
-
process_attachment(attachment)
-
end
-
end
-
-
def conversation
-
@conversation ||= set_conversation_based_on_inbox_config
-
end
-
-
def set_conversation_based_on_inbox_config
-
if @inbox.lock_to_single_conversation
-
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
-
else
-
find_or_build_for_multiple_conversations
-
end
-
end
-
-
def find_or_build_for_multiple_conversations
-
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
-
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
-
return build_conversation if last_conversation.nil?
-
-
last_conversation
-
end
-
-
def build_conversation
-
Conversation.create!(conversation_params.merge(
-
contact_inbox_id: @contact_inbox.id
-
))
-
end
-
-
def location_params(attachment)
-
lat = attachment['payload']['coordinates']['lat']
-
long = attachment['payload']['coordinates']['long']
-
{
-
external_url: attachment['url'],
-
coordinates_lat: lat,
-
coordinates_long: long,
-
fallback_title: attachment['title']
-
}
-
end
-
-
def fallback_params(attachment)
-
{
-
fallback_title: attachment['title'],
-
external_url: attachment['url']
-
}
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact_inbox.contact_id
-
}
-
end
-
-
def message_params
-
{
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
message_type: @message_type,
-
content: response.content,
-
source_id: response.identifier,
-
content_attributes: {
-
in_reply_to_external_id: response.in_reply_to_external_id
-
},
-
sender: @outgoing_echo ? nil : @contact_inbox.contact
-
}
-
end
-
-
def process_contact_params_result(result)
-
{
-
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
-
account_id: @inbox.account_id,
-
avatar_url: result['profile_pic']
-
}
-
end
-
-
# rubocop:disable Metrics/AbcSize
-
# rubocop:disable Metrics/MethodLength
-
def contact_params
-
begin
-
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
-
result = k.get_object(@sender_id) || {}
-
rescue Koala::Facebook::AuthenticationError => e
-
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
-
Rails.logger.error e
-
@inbox.channel.authorization_error!
-
raise
-
rescue Koala::Facebook::ClientError => e
-
result = {}
-
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
-
# We don't need to capture this error as we don't care about contact params in case of echo messages
-
if e.message.include?('2018218')
-
Rails.logger.warn e
-
else
-
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
-
end
-
rescue StandardError => e
-
result = {}
-
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
-
end
-
process_contact_params_result(result)
-
end
-
# rubocop:enable Metrics/AbcSize
-
# rubocop:enable Metrics/MethodLength
-
end
-
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
-
attr_reader :messaging
-
-
def initialize(messaging, inbox, outgoing_echo: false)
-
super()
-
@messaging = messaging
-
@inbox = inbox
-
@outgoing_echo = outgoing_echo
-
end
-
-
def perform
-
return if @inbox.channel.reauthorization_required?
-
-
ActiveRecord::Base.transaction do
-
build_message
-
end
-
rescue StandardError => e
-
handle_error(e)
-
end
-
-
private
-
-
def attachments
-
@messaging[:message][:attachments] || {}
-
end
-
-
def message_type
-
@outgoing_echo ? :outgoing : :incoming
-
end
-
-
def message_identifier
-
message[:mid]
-
end
-
-
def message_source_id
-
@outgoing_echo ? recipient_id : sender_id
-
end
-
-
def message_is_unsupported?
-
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
-
end
-
-
def sender_id
-
@messaging[:sender][:id]
-
end
-
-
def recipient_id
-
@messaging[:recipient][:id]
-
end
-
-
def message
-
@messaging[:message]
-
end
-
-
def contact
-
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
-
end
-
-
def conversation
-
@conversation ||= set_conversation_based_on_inbox_config
-
end
-
-
def set_conversation_based_on_inbox_config
-
if @inbox.lock_to_single_conversation
-
find_conversation_scope.order(created_at: :desc).first || build_conversation
-
else
-
find_or_build_for_multiple_conversations
-
end
-
end
-
-
def find_conversation_scope
-
Conversation.where(conversation_params)
-
end
-
-
def find_or_build_for_multiple_conversations
-
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
-
return build_conversation if last_conversation.nil?
-
-
last_conversation
-
end
-
-
def message_content
-
@messaging[:message][:text]
-
end
-
-
def story_reply_attributes
-
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
-
end
-
-
def message_reply_attributes
-
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
-
end
-
-
def build_message
-
# Duplicate webhook events may be sent for the same message
-
# when a user is connected to the Instagram account through both Messenger and Instagram login.
-
# There is chance for echo events to be sent for the same message.
-
# Therefore, we need to check if the message already exists before creating it.
-
return if message_already_exists?
-
-
return if message_content.blank? && all_unsupported_files?
-
-
@message = conversation.messages.create!(message_params)
-
save_story_id
-
-
attachments.each do |attachment|
-
process_attachment(attachment)
-
end
-
end
-
-
def save_story_id
-
return if story_reply_attributes.blank?
-
-
@message.save_story_info(story_reply_attributes)
-
end
-
-
def build_conversation
-
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
-
Conversation.create!(conversation_params.merge(
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: additional_conversation_attributes
-
))
-
end
-
-
def additional_conversation_attributes
-
{}
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: contact.id
-
}
-
end
-
-
def message_params
-
params = {
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
message_type: message_type,
-
source_id: message_identifier,
-
content: message_content,
-
sender: @outgoing_echo ? nil : contact,
-
content_attributes: {
-
in_reply_to_external_id: message_reply_attributes
-
}
-
}
-
-
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
-
params
-
end
-
-
def message_already_exists?
-
cw_message = conversation.messages.where(
-
source_id: @messaging[:message][:mid]
-
).first
-
-
cw_message.present?
-
end
-
-
def all_unsupported_files?
-
return if attachments.empty?
-
-
attachments_type = attachments.pluck(:type).uniq.first
-
unsupported_file_type?(attachments_type)
-
end
-
-
def handle_error(error)
-
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
-
true
-
end
-
-
# Abstract methods to be implemented by subclasses
-
def get_story_object_from_source_id(source_id)
-
raise NotImplementedError
-
end
-
end
-
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
-
def initialize(messaging, inbox, outgoing_echo: false)
-
super(messaging, inbox, outgoing_echo: outgoing_echo)
-
end
-
-
private
-
-
def get_story_object_from_source_id(source_id)
-
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
-
-
response = HTTParty.get(url)
-
-
return JSON.parse(response.body).with_indifferent_access if response.success?
-
-
# Create message first if it doesn't exist
-
@message ||= conversation.messages.create!(message_params)
-
handle_error_response(response)
-
nil
-
end
-
-
def handle_error_response(response)
-
parsed_response = JSON.parse(response.body)
-
error_code = parsed_response.dig('error', 'code')
-
-
# https://developers.facebook.com/docs/messenger-platform/error-codes
-
# Access token has expired or become invalid.
-
channel.authorization_error! if error_code == 190
-
-
# There was a problem scraping data from the provided link.
-
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
-
if error_code == 1_609_005
-
@message.attachments.destroy_all
-
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
-
end
-
-
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
-
end
-
-
def base_uri
-
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
-
end
-
end
-
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
-
def initialize(messaging, inbox, outgoing_echo: false)
-
super(messaging, inbox, outgoing_echo: outgoing_echo)
-
end
-
-
private
-
-
def get_story_object_from_source_id(source_id)
-
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
-
k.get_object(source_id, fields: %w[story from]) || {}
-
rescue Koala::Facebook::AuthenticationError
-
@inbox.channel.authorization_error!
-
raise
-
rescue Koala::Facebook::ClientError => e
-
# The exception occurs when we are trying fetch the deleted story or blocked story.
-
@message.attachments.destroy_all
-
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
-
Rails.logger.error e
-
{}
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
-
{}
-
end
-
-
def find_conversation_scope
-
Conversation.where(conversation_params)
-
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
-
end
-
-
def additional_conversation_attributes
-
{ type: 'instagram_direct_message' }
-
end
-
end
-
class Messages::MessageBuilder
-
include ::FileTypeHelper
-
attr_reader :message
-
-
def initialize(user, conversation, params)
-
@params = params
-
@private = params[:private] || false
-
@conversation = conversation
-
@user = user
-
@message_type = params[:message_type] || 'outgoing'
-
@attachments = params[:attachments]
-
@automation_rule = content_attributes&.dig(:automation_rule_id)
-
return unless params.instance_of?(ActionController::Parameters)
-
-
@in_reply_to = content_attributes&.dig(:in_reply_to)
-
@items = content_attributes&.dig(:items)
-
end
-
-
def perform
-
@message = @conversation.messages.build(message_params)
-
process_attachments
-
process_emails
-
@message.save!
-
@message
-
end
-
-
private
-
-
# Extracts content attributes from the given params.
-
# - Converts ActionController::Parameters to a regular hash if needed.
-
# - Attempts to parse a JSON string if content is a string.
-
# - Returns an empty hash if content is not present, if there's a parsing error, or if it's an unexpected type.
-
def content_attributes
-
params = convert_to_hash(@params)
-
content_attributes = params.fetch(:content_attributes, {})
-
-
return parse_json(content_attributes) if content_attributes.is_a?(String)
-
return content_attributes if content_attributes.is_a?(Hash)
-
-
{}
-
end
-
-
# Converts the given object to a hash.
-
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
-
# Otherwise, returns the object as-is.
-
def convert_to_hash(obj)
-
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
-
-
obj
-
end
-
-
# Attempts to parse a string as JSON.
-
# If successful, returns the parsed hash with symbolized names.
-
# If unsuccessful, returns nil.
-
def parse_json(content)
-
JSON.parse(content, symbolize_names: true)
-
rescue JSON::ParserError
-
{}
-
end
-
-
def process_attachments
-
return if @attachments.blank?
-
-
@attachments.each do |uploaded_attachment|
-
attachment = @message.attachments.build(
-
account_id: @message.account_id,
-
file: uploaded_attachment
-
)
-
-
attachment.file_type = if uploaded_attachment.is_a?(String)
-
file_type_by_signed_id(
-
uploaded_attachment
-
)
-
else
-
file_type(uploaded_attachment&.content_type)
-
end
-
end
-
end
-
-
def process_emails
-
return unless @conversation.inbox&.inbox_type == 'Email'
-
-
cc_emails = process_email_string(@params[:cc_emails])
-
bcc_emails = process_email_string(@params[:bcc_emails])
-
to_emails = process_email_string(@params[:to_emails])
-
-
all_email_addresses = cc_emails + bcc_emails + to_emails
-
validate_email_addresses(all_email_addresses)
-
-
@message.content_attributes[:cc_emails] = cc_emails
-
@message.content_attributes[:bcc_emails] = bcc_emails
-
@message.content_attributes[:to_emails] = to_emails
-
end
-
-
def process_email_string(email_string)
-
return [] if email_string.blank?
-
-
email_string.gsub(/\s+/, '').split(',')
-
end
-
-
def validate_email_addresses(all_emails)
-
all_emails&.each do |email|
-
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
-
end
-
end
-
-
def message_type
-
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
-
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
-
end
-
-
@message_type
-
end
-
-
def sender
-
message_type == 'outgoing' ? (message_sender || @user) : @conversation.contact
-
end
-
-
def external_created_at
-
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
-
end
-
-
def automation_rule_id
-
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
-
end
-
-
def campaign_id
-
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
-
end
-
-
def template_params
-
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
-
end
-
-
def message_sender
-
return if @params[:sender_type] != 'AgentBot'
-
-
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
-
end
-
-
def message_params
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: message_type,
-
content: @params[:content],
-
private: @private,
-
sender: sender,
-
content_type: @params[:content_type],
-
items: @items,
-
in_reply_to: @in_reply_to,
-
echo_id: @params[:echo_id],
-
source_id: @params[:source_id]
-
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
-
end
-
end
-
class Messages::Messenger::MessageBuilder
-
include ::FileTypeHelper
-
-
def process_attachment(attachment)
-
# This check handles very rare case if there are multiple files to attach with only one usupported file
-
return if unsupported_file_type?(attachment['type'])
-
-
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
-
attachment_obj.save!
-
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
-
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
-
update_attachment_file_type(attachment_obj)
-
end
-
-
def attach_file(attachment, file_url)
-
attachment_file = Down.download(
-
file_url
-
)
-
attachment.file.attach(
-
io: attachment_file,
-
filename: attachment_file.original_filename,
-
content_type: attachment_file.content_type
-
)
-
end
-
-
def attachment_params(attachment)
-
file_type = attachment['type'].to_sym
-
params = { file_type: file_type, account_id: @message.account_id }
-
-
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
-
params.merge!(file_type_params(attachment))
-
elsif file_type == :location
-
params.merge!(location_params(attachment))
-
elsif file_type == :fallback
-
params.merge!(fallback_params(attachment))
-
end
-
-
params
-
end
-
-
def file_type_params(attachment)
-
{
-
external_url: attachment['payload']['url'],
-
remote_file_url: attachment['payload']['url']
-
}
-
end
-
-
def update_attachment_file_type(attachment)
-
return if @message.reload.attachments.blank?
-
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
-
-
attachment.file_type = file_type(attachment.file&.content_type)
-
attachment.save!
-
end
-
-
def fetch_story_link(attachment)
-
message = attachment.message
-
result = get_story_object_from_source_id(message.source_id)
-
-
return if result.blank?
-
-
story_id = result['story']['mention']['id']
-
story_sender = result['from']['username']
-
message.content_attributes[:story_sender] = story_sender
-
message.content_attributes[:story_id] = story_id
-
message.content_attributes[:image_type] = 'story_mention'
-
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
-
message.save!
-
end
-
-
# This is a placeholder method to be overridden by child classes
-
def get_story_object_from_source_id(_source_id)
-
{}
-
end
-
-
private
-
-
def unsupported_file_type?(attachment_type)
-
[:template, :unsupported_type].include? attachment_type.to_sym
-
end
-
end
-
class NotificationBuilder
-
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor]
-
-
def perform
-
build_notification
-
end
-
-
private
-
-
def current_user
-
Current.user
-
end
-
-
def user_subscribed_to_notification?
-
notification_setting = user.notification_settings.find_by(account_id: account.id)
-
# added for the case where an assignee might be removed from the account but remains in conversation
-
return false if notification_setting.blank?
-
-
return true if notification_setting.public_send("email_#{notification_type}?")
-
return true if notification_setting.public_send("push_#{notification_type}?")
-
-
false
-
end
-
-
def build_notification
-
# Create conversation_creation notification only if user is subscribed to it
-
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
-
# skip notifications for blocked conversations except for user mentions
-
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
-
-
user.notifications.create!(
-
notification_type: notification_type,
-
account: account,
-
primary_actor: primary_actor,
-
# secondary_actor is secondary_actor if present, else current_user
-
secondary_actor: secondary_actor || current_user
-
)
-
end
-
end
-
class NotificationSubscriptionBuilder
-
pattr_initialize [:params, :user!]
-
-
def perform
-
# if multiple accounts were used to login in same browser
-
move_subscription_to_user if identifier_subscription && identifier_subscription.user_id != user.id
-
identifier_subscription.blank? ? build_identifier_subscription : update_identifier_subscription
-
identifier_subscription
-
end
-
-
private
-
-
def identifier
-
@identifier ||= params[:subscription_attributes][:endpoint] if params[:subscription_type] == 'browser_push'
-
@identifier ||= params[:subscription_attributes][:device_id] if params[:subscription_type] == 'fcm'
-
@identifier
-
end
-
-
def identifier_subscription
-
@identifier_subscription ||= NotificationSubscription.find_by(identifier: identifier)
-
end
-
-
def move_subscription_to_user
-
@identifier_subscription.update(user_id: user.id)
-
end
-
-
def build_identifier_subscription
-
@identifier_subscription = user.notification_subscriptions.create!(params.merge(identifier: identifier))
-
end
-
-
def update_identifier_subscription
-
identifier_subscription.update(params.merge(identifier: identifier))
-
end
-
end
-
class V2::ReportBuilder
-
include DateRangeHelper
-
include ReportHelper
-
attr_reader :account, :params
-
-
DEFAULT_GROUP_BY = 'day'.freeze
-
AGENT_RESULTS_PER_PAGE = 25
-
-
def initialize(account, params)
-
@account = account
-
@params = params
-
-
timezone_offset = (params[:timezone_offset] || 0).to_f
-
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
-
end
-
-
def timeseries
-
return send(params[:metric]) if metric_valid?
-
-
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
-
{}
-
end
-
-
# For backward compatible with old report
-
def build
-
if %w[avg_first_response_time avg_resolution_time reply_time].include?(params[:metric])
-
timeseries.each_with_object([]) do |p, arr|
-
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
-
end
-
else
-
timeseries.each_with_object([]) do |p, arr|
-
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
-
end
-
end
-
end
-
-
def summary
-
{
-
conversations_count: conversations.count,
-
incoming_messages_count: incoming_messages.count,
-
outgoing_messages_count: outgoing_messages.count,
-
avg_first_response_time: avg_first_response_time_summary,
-
avg_resolution_time: avg_resolution_time_summary,
-
resolutions_count: resolutions.count,
-
reply_time: reply_time_summary
-
}
-
end
-
-
def short_summary
-
{
-
conversations_count: conversations.count,
-
avg_first_response_time: avg_first_response_time_summary,
-
avg_resolution_time: avg_resolution_time_summary
-
}
-
end
-
-
def bot_summary
-
{
-
bot_resolutions_count: bot_resolutions.count,
-
bot_handoffs_count: bot_handoffs.count
-
}
-
end
-
-
def conversation_metrics
-
if params[:type].equal?(:account)
-
live_conversations
-
else
-
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
-
end
-
end
-
-
private
-
-
def metric_valid?
-
%w[conversations_count
-
incoming_messages_count
-
outgoing_messages_count
-
avg_first_response_time
-
avg_resolution_time reply_time
-
resolutions_count
-
bot_resolutions_count
-
bot_handoffs_count
-
reply_time].include?(params[:metric])
-
end
-
-
def inbox
-
@inbox ||= account.inboxes.find(params[:id])
-
end
-
-
def user
-
@user ||= account.users.find(params[:id])
-
end
-
-
def label
-
@label ||= account.labels.find(params[:id])
-
end
-
-
def team
-
@team ||= account.teams.find(params[:id])
-
end
-
-
def get_grouped_values(object_scope)
-
@grouped_values = object_scope.group_by_period(
-
params[:group_by] || DEFAULT_GROUP_BY,
-
:created_at,
-
default_value: 0,
-
range: range,
-
permit: %w[day week month year hour],
-
time_zone: @timezone
-
)
-
end
-
-
def agent_metrics
-
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
-
account_users.each_with_object([]) do |account_user, arr|
-
@user = account_user.user
-
arr << {
-
id: @user.id,
-
name: @user.name,
-
email: @user.email,
-
thumbnail: @user.avatar_url,
-
availability: account_user.availability_status,
-
metric: live_conversations
-
}
-
end
-
end
-
-
def live_conversations
-
@open_conversations = scope.conversations.where(account_id: @account.id).open
-
metric = {
-
open: @open_conversations.count,
-
unattended: @open_conversations.unattended.count
-
}
-
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
-
metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account)
-
metric
-
end
-
end
-
class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
-
pattr_initialize [:account!, :params!]
-
-
def build
-
load_data
-
prepare_report
-
end
-
-
private
-
-
attr_reader :conversations_count, :resolved_count,
-
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
-
-
def fetch_conversations_count
-
account.conversations.where(created_at: range).group('assignee_id').count
-
end
-
-
def prepare_report
-
account.account_users.map do |account_user|
-
build_agent_stats(account_user)
-
end
-
end
-
-
def build_agent_stats(account_user)
-
user_id = account_user.user_id
-
{
-
id: user_id,
-
conversations_count: conversations_count[user_id] || 0,
-
resolved_conversations_count: resolved_count[user_id] || 0,
-
avg_resolution_time: avg_resolution_time[user_id],
-
avg_first_response_time: avg_first_response_time[user_id],
-
avg_reply_time: avg_reply_time[user_id]
-
}
-
end
-
-
def group_by_key
-
:user_id
-
end
-
end
-
class V2::Reports::BaseSummaryBuilder
-
include DateRangeHelper
-
-
def build
-
load_data
-
prepare_report
-
end
-
-
private
-
-
def load_data
-
@conversations_count = fetch_conversations_count
-
@resolved_count = fetch_resolved_count
-
@avg_resolution_time = fetch_average_time('conversation_resolved')
-
@avg_first_response_time = fetch_average_time('first_response')
-
@avg_reply_time = fetch_average_time('reply_time')
-
end
-
-
def reporting_events
-
@reporting_events ||= account.reporting_events.where(created_at: range)
-
end
-
-
def fetch_conversations_count
-
# Override this method
-
end
-
-
def fetch_average_time(event_name)
-
get_grouped_average(reporting_events.where(name: event_name))
-
end
-
-
def fetch_resolved_count
-
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
-
end
-
-
def group_by_key
-
# Override this method
-
end
-
-
def prepare_report
-
# Override this method
-
end
-
-
def get_grouped_average(events)
-
events.group(group_by_key).average(average_value_key)
-
end
-
-
def average_value_key
-
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
-
end
-
end
-
class V2::Reports::BotMetricsBuilder
-
include DateRangeHelper
-
attr_reader :account, :params
-
-
def initialize(account, params)
-
@account = account
-
@params = params
-
end
-
-
def metrics
-
{
-
conversation_count: bot_conversations.count,
-
message_count: bot_messages.count,
-
resolution_rate: bot_resolution_rate.to_i,
-
handoff_rate: bot_handoff_rate.to_i
-
}
-
end
-
-
private
-
-
def bot_activated_inbox_ids
-
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
-
end
-
-
def bot_conversations
-
@bot_conversations ||= account.conversations.where(inbox_id: bot_activated_inbox_ids).where(created_at: range)
-
end
-
-
def bot_messages
-
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
-
end
-
-
def bot_resolutions_count
-
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
-
created_at: range).distinct.count
-
end
-
-
def bot_handoffs_count
-
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
-
created_at: range).distinct.count
-
end
-
-
def bot_resolution_rate
-
return 0 if bot_conversations.count.zero?
-
-
bot_resolutions_count.to_f / bot_conversations.count * 100
-
end
-
-
def bot_handoff_rate
-
return 0 if bot_conversations.count.zero?
-
-
bot_handoffs_count.to_f / bot_conversations.count * 100
-
end
-
end
-
class V2::Reports::Conversations::BaseReportBuilder
-
pattr_initialize :account, :params
-
-
private
-
-
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
-
COUNT_METRICS = %w[
-
conversations_count
-
incoming_messages_count
-
outgoing_messages_count
-
resolutions_count
-
bot_resolutions_count
-
bot_handoffs_count
-
].freeze
-
-
def builder_class(metric)
-
case metric
-
when *AVG_METRICS
-
V2::Reports::Timeseries::AverageReportBuilder
-
when *COUNT_METRICS
-
V2::Reports::Timeseries::CountReportBuilder
-
end
-
end
-
-
def log_invalid_metric
-
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
-
-
{}
-
end
-
end
-
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
-
def summary
-
{
-
conversations_count: count('conversations_count'),
-
incoming_messages_count: count('incoming_messages_count'),
-
outgoing_messages_count: count('outgoing_messages_count'),
-
avg_first_response_time: count('avg_first_response_time'),
-
avg_resolution_time: count('avg_resolution_time'),
-
resolutions_count: count('resolutions_count'),
-
reply_time: count('reply_time')
-
}
-
end
-
-
def bot_summary
-
{
-
bot_resolutions_count: count('bot_resolutions_count'),
-
bot_handoffs_count: count('bot_handoffs_count')
-
}
-
end
-
-
private
-
-
def count(metric)
-
builder_class(metric).new(account, builder_params(metric)).aggregate_value
-
end
-
-
def builder_params(metric)
-
params.merge({ metric: metric })
-
end
-
end
-
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
-
def timeseries
-
perform_action(:timeseries)
-
end
-
-
def aggregate_value
-
perform_action(:aggregate_value)
-
end
-
-
private
-
-
def perform_action(method_name)
-
return builder.new(account, params).public_send(method_name) if builder.present?
-
-
log_invalid_metric
-
end
-
-
def builder
-
builder_class(params[:metric])
-
end
-
end
-
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
-
pattr_initialize [:account!, :params!]
-
-
def build
-
load_data
-
prepare_report
-
end
-
-
private
-
-
attr_reader :conversations_count, :resolved_count,
-
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
-
-
def load_data
-
@conversations_count = fetch_conversations_count
-
@resolved_count = fetch_resolved_count
-
@avg_resolution_time = fetch_average_time('conversation_resolved')
-
@avg_first_response_time = fetch_average_time('first_response')
-
@avg_reply_time = fetch_average_time('reply_time')
-
end
-
-
def fetch_conversations_count
-
account.conversations.where(created_at: range).group(group_by_key).count
-
end
-
-
def prepare_report
-
account.inboxes.map do |inbox|
-
build_inbox_stats(inbox)
-
end
-
end
-
-
def build_inbox_stats(inbox)
-
{
-
id: inbox.id,
-
conversations_count: conversations_count[inbox.id] || 0,
-
resolved_conversations_count: resolved_count[inbox.id] || 0,
-
avg_resolution_time: avg_resolution_time[inbox.id],
-
avg_first_response_time: avg_first_response_time[inbox.id],
-
avg_reply_time: avg_reply_time[inbox.id]
-
}
-
end
-
-
def group_by_key
-
:inbox_id
-
end
-
-
def average_value_key
-
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
-
end
-
end
-
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
-
pattr_initialize [:account!, :params!]
-
-
private
-
-
attr_reader :conversations_count, :resolved_count,
-
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
-
-
def fetch_conversations_count
-
account.conversations.where(created_at: range).group(:team_id).count
-
end
-
-
def reporting_events
-
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
-
end
-
-
def prepare_report
-
account.teams.map do |team|
-
build_team_stats(team)
-
end
-
end
-
-
def build_team_stats(team)
-
{
-
id: team.id,
-
conversations_count: conversations_count[team.id] || 0,
-
resolved_conversations_count: resolved_count[team.id] || 0,
-
avg_resolution_time: avg_resolution_time[team.id],
-
avg_first_response_time: avg_first_response_time[team.id],
-
avg_reply_time: avg_reply_time[team.id]
-
}
-
end
-
-
def group_by_key
-
'conversations.team_id'
-
end
-
end
-
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
-
def timeseries
-
grouped_average_time = reporting_events.average(average_value_key)
-
grouped_event_count = reporting_events.count
-
grouped_average_time.each_with_object([]) do |element, arr|
-
event_date, average_time = element
-
arr << {
-
value: average_time,
-
timestamp: event_date.in_time_zone(timezone).to_i,
-
count: grouped_event_count[event_date]
-
}
-
end
-
end
-
-
def aggregate_value
-
object_scope.average(average_value_key)
-
end
-
-
private
-
-
def event_name
-
metric_to_event_name = {
-
avg_first_response_time: :first_response,
-
avg_resolution_time: :conversation_resolved,
-
reply_time: :reply_time
-
}
-
metric_to_event_name[params[:metric].to_sym]
-
end
-
-
def object_scope
-
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
-
end
-
-
def reporting_events
-
@grouped_values = object_scope.group_by_period(
-
group_by,
-
:created_at,
-
default_value: 0,
-
range: range,
-
permit: %w[day week month year hour],
-
time_zone: timezone
-
)
-
end
-
-
def average_value_key
-
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
-
end
-
end
-
class V2::Reports::Timeseries::BaseTimeseriesBuilder
-
include TimezoneHelper
-
include DateRangeHelper
-
DEFAULT_GROUP_BY = 'day'.freeze
-
-
pattr_initialize :account, :params
-
-
def scope
-
case params[:type].to_sym
-
when :account
-
account
-
when :inbox
-
inbox
-
when :agent
-
user
-
when :label
-
label
-
when :team
-
team
-
end
-
end
-
-
def inbox
-
@inbox ||= account.inboxes.find(params[:id])
-
end
-
-
def user
-
@user ||= account.users.find(params[:id])
-
end
-
-
def label
-
@label ||= account.labels.find(params[:id])
-
end
-
-
def team
-
@team ||= account.teams.find(params[:id])
-
end
-
-
def group_by
-
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
-
end
-
-
def timezone
-
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
-
end
-
end
-
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
-
def timeseries
-
grouped_count.each_with_object([]) do |element, arr|
-
event_date, event_count = element
-
-
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
-
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
-
# because it converts the date to 12:00 AM server timezone.
-
# The desired output should be 12:00 AM in the specified timezone.
-
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
-
end
-
end
-
-
def aggregate_value
-
object_scope.count
-
end
-
-
private
-
-
def metric
-
@metric ||= params[:metric]
-
end
-
-
def object_scope
-
send("scope_for_#{metric}")
-
end
-
-
def scope_for_conversations_count
-
scope.conversations.where(account_id: account.id, created_at: range)
-
end
-
-
def scope_for_incoming_messages_count
-
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
-
end
-
-
def scope_for_outgoing_messages_count
-
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
-
end
-
-
def scope_for_resolutions_count
-
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
-
name: :conversation_resolved,
-
conversations: { status: :resolved }, created_at: range
-
).distinct
-
end
-
-
def scope_for_bot_resolutions_count
-
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
-
name: :conversation_bot_resolved,
-
conversations: { status: :resolved }, created_at: range
-
).distinct
-
end
-
-
def scope_for_bot_handoffs_count
-
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
-
name: :conversation_bot_handoff,
-
created_at: range
-
).distinct
-
end
-
-
def grouped_count
-
@grouped_values = object_scope.group_by_period(
-
group_by,
-
:created_at,
-
default_value: 0,
-
range: range,
-
permit: %w[day week month year hour],
-
time_zone: timezone
-
).count
-
end
-
end
-
class ApplicationCable::Channel < ActionCable::Channel::Base
-
end
-
class ApplicationCable::Connection < ActionCable::Connection::Base
-
end
-
class RoomChannel < ApplicationCable::Channel
-
def subscribed
-
# TODO: should we only do ensure stream if current account is present?
-
# for now going ahead with guard clauses in update_subscription and broadcast_presence
-
current_user
-
current_account
-
ensure_stream
-
update_subscription
-
broadcast_presence
-
end
-
-
def update_presence
-
update_subscription
-
broadcast_presence
-
end
-
-
private
-
-
def broadcast_presence
-
return if @current_account.blank?
-
-
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
-
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
-
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
-
end
-
-
def ensure_stream
-
stream_from pubsub_token
-
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
-
end
-
-
def update_subscription
-
return if @current_account.blank?
-
-
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
-
end
-
-
def pubsub_token
-
@pubsub_token ||= params[:pubsub_token]
-
end
-
-
def current_user
-
@current_user ||= if params[:user_id].blank?
-
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
-
else
-
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
-
end
-
end
-
-
def current_account
-
return if current_user.blank?
-
-
@current_account ||= if @current_user.is_a? Contact
-
@current_user.account
-
else
-
@current_user.accounts.find(params[:account_id])
-
end
-
end
-
end
-
class AndroidAppController < ApplicationController
-
def assetlinks
-
render layout: false
-
end
-
end
-
class Api::BaseController < ApplicationController
-
include AccessTokenAuthHelper
-
respond_to :json
-
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
-
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
-
before_action :authenticate_user!, unless: :authenticate_by_access_token?
-
-
private
-
-
def authenticate_by_access_token?
-
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
-
end
-
-
def check_authorization(model = nil)
-
model ||= controller_name.classify.constantize
-
-
authorize(model)
-
end
-
-
def check_admin_authorization?
-
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
-
end
-
end
-
class Api::V1::Accounts::Actions::ContactMergesController < Api::V1::Accounts::BaseController
-
before_action :set_base_contact, only: [:create]
-
before_action :set_mergee_contact, only: [:create]
-
-
def create
-
contact_merge_action = ContactMergeAction.new(
-
account: Current.account,
-
base_contact: @base_contact,
-
mergee_contact: @mergee_contact
-
)
-
contact_merge_action.perform
-
end
-
-
private
-
-
def set_base_contact
-
@base_contact = contacts.find(params[:base_contact_id])
-
end
-
-
def set_mergee_contact
-
@mergee_contact = contacts.find(params[:mergee_contact_id])
-
end
-
-
def contacts
-
@contacts ||= Current.account.contacts
-
end
-
end
-
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
-
before_action :current_account
-
before_action :check_authorization
-
before_action :agent_bot, except: [:index, :create]
-
-
def index
-
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
-
end
-
-
def show; end
-
-
def create
-
@agent_bot = Current.account.agent_bots.create!(permitted_params.except(:avatar_url))
-
process_avatar_from_url
-
end
-
-
def update
-
@agent_bot.update!(permitted_params.except(:avatar_url))
-
process_avatar_from_url
-
end
-
-
def avatar
-
@agent_bot.avatar.purge if @agent_bot.avatar.attached?
-
@agent_bot
-
end
-
-
def destroy
-
@agent_bot.destroy!
-
head :ok
-
end
-
-
private
-
-
def agent_bot
-
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
-
@agent_bot ||= Current.account.agent_bots.find(params[:id])
-
end
-
-
def permitted_params
-
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
-
end
-
-
def process_avatar_from_url
-
::Avatar::AvatarFromUrlJob.perform_later(@agent_bot, params[:avatar_url]) if params[:avatar_url].present?
-
end
-
end
-
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
-
before_action :fetch_agent, except: [:create, :index, :bulk_create]
-
before_action :check_authorization
-
before_action :validate_limit, only: [:create]
-
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
-
-
def index
-
@agents = agents
-
end
-
-
def create
-
builder = AgentBuilder.new(
-
email: new_agent_params['email'],
-
name: new_agent_params['name'],
-
role: new_agent_params['role'],
-
availability: new_agent_params['availability'],
-
auto_offline: new_agent_params['auto_offline'],
-
inviter: current_user,
-
account: Current.account
-
)
-
-
@agent = builder.perform
-
end
-
-
def update
-
@agent.update!(agent_params.slice(:name).compact)
-
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
-
end
-
-
def destroy
-
@agent.current_account_user.destroy!
-
delete_user_record(@agent)
-
head :ok
-
end
-
-
def bulk_create
-
emails = params[:emails]
-
-
emails.each do |email|
-
builder = AgentBuilder.new(
-
email: email,
-
name: email.split('@').first,
-
inviter: current_user,
-
account: Current.account
-
)
-
begin
-
builder.perform
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
-
end
-
end
-
-
# This endpoint is used to bulk create agents during onboarding
-
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
-
Current.account.custom_attributes.delete('onboarding_step')
-
Current.account.save!
-
head :ok
-
end
-
-
private
-
-
def check_authorization
-
super(User)
-
end
-
-
def fetch_agent
-
@agent = agents.find(params[:id])
-
end
-
-
def account_user_attributes
-
[:role, :availability, :auto_offline]
-
end
-
-
def allowed_agent_params
-
[:name, :email, :role, :availability, :auto_offline]
-
end
-
-
def agent_params
-
params.require(:agent).permit(allowed_agent_params)
-
end
-
-
def new_agent_params
-
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
-
end
-
-
def agents
-
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
-
end
-
-
def validate_limit_for_bulk_create
-
limit_available = params[:emails].count <= available_agent_count
-
-
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
-
end
-
-
def validate_limit
-
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
-
end
-
-
def available_agent_count
-
Current.account.usage_limits[:agents] - agents.count
-
end
-
-
def can_add_agent?
-
available_agent_count.positive?
-
end
-
-
def delete_user_record(agent)
-
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
-
end
-
end
-
-
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
-
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
-
before_action :portal
-
before_action :check_authorization
-
before_action :fetch_article, except: [:index, :create, :reorder]
-
before_action :set_current_page, only: [:index]
-
-
def index
-
@portal_articles = @portal.articles
-
-
set_article_count
-
-
@articles = @articles.search(list_params)
-
-
@articles = if list_params[:category_slug].present?
-
@articles.order_by_position.page(@current_page)
-
else
-
@articles.order_by_updated_at.page(@current_page)
-
end
-
end
-
-
def show; end
-
def edit; end
-
-
def create
-
@article = @portal.articles.create!(article_params)
-
@article.associate_root_article(article_params[:associated_article_id])
-
@article.draft!
-
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
-
end
-
-
def update
-
@article.update!(article_params) if params[:article].present?
-
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
-
end
-
-
def destroy
-
@article.destroy!
-
head :ok
-
end
-
-
def reorder
-
Article.update_positions(params[:positions_hash])
-
head :ok
-
end
-
-
private
-
-
def set_article_count
-
# Search the params without status and author_id, use this to
-
# compute mine count published draft etc
-
base_search_params = list_params.except(:status, :author_id)
-
@articles = @portal_articles.search(base_search_params)
-
-
@articles_count = @articles.count
-
@mine_articles_count = @articles.search_by_author(Current.user.id).count
-
@published_articles_count = @articles.published.count
-
@draft_articles_count = @articles.draft.count
-
@archived_articles_count = @articles.archived.count
-
end
-
-
def fetch_article
-
@article = @portal.articles.find(params[:id])
-
end
-
-
def portal
-
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
-
end
-
-
def article_params
-
params.require(:article).permit(
-
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
-
:locale, meta: [:title,
-
:description,
-
{ tags: [] }]
-
)
-
end
-
-
def list_params
-
params.permit(:locale, :query, :page, :category_slug, :status, :author_id)
-
end
-
-
def set_current_page
-
@current_page = params[:page] || 1
-
end
-
end
-
class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController
-
before_action :fetch_inboxes
-
-
def index
-
agent_ids = @inboxes.map do |inbox|
-
authorize inbox, :show?
-
member_ids = inbox.members.pluck(:user_id)
-
member_ids
-
end
-
agent_ids = agent_ids.inject(:&)
-
agents = Current.account.users.where(id: agent_ids)
-
@assignable_agents = (agents + Current.account.administrators).uniq
-
end
-
-
private
-
-
def fetch_inboxes
-
@inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids])
-
end
-
-
def permitted_params
-
params.permit(inbox_ids: [])
-
end
-
end
-
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
-
before_action :check_authorization
-
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
-
-
def index
-
@automation_rules = Current.account.automation_rules
-
end
-
-
def show; end
-
-
def create
-
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
-
@automation_rule.actions = params[:actions]
-
@automation_rule.conditions = params[:conditions]
-
-
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
-
-
@automation_rule.save!
-
process_attachments
-
@automation_rule
-
end
-
-
def update
-
ActiveRecord::Base.transaction do
-
automation_rule_update
-
process_attachments
-
-
rescue StandardError => e
-
Rails.logger.error e
-
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
-
end
-
end
-
-
def destroy
-
@automation_rule.destroy!
-
head :ok
-
end
-
-
def clone
-
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
-
new_rule = automation_rule.dup
-
new_rule.save!
-
@automation_rule = new_rule
-
end
-
-
def process_attachments
-
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
-
return if actions.blank?
-
-
actions.each do |action|
-
blob_id = action['action_params']
-
blob = ActiveStorage::Blob.find_by(id: blob_id)
-
@automation_rule.files.attach(blob)
-
end
-
end
-
-
private
-
-
def automation_rule_update
-
@automation_rule.update!(automation_rules_permit)
-
@automation_rule.actions = params[:actions] if params[:actions]
-
@automation_rule.conditions = params[:conditions] if params[:conditions]
-
@automation_rule.save!
-
end
-
-
def automation_rules_permit
-
params.permit(
-
:name, :description, :event_name, :account_id, :active,
-
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
-
actions: [:action_name, { action_params: [] }]
-
)
-
end
-
-
def fetch_automation_rule
-
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
-
end
-
end
-
class Api::V1::Accounts::BaseController < Api::BaseController
-
include SwitchLocale
-
include EnsureCurrentAccountHelper
-
before_action :current_account
-
around_action :switch_locale_using_account_locale
-
end
-
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
-
before_action :type_matches?
-
-
def create
-
if type_matches?
-
::BulkActionsJob.perform_later(
-
account: @current_account,
-
user: current_user,
-
params: permitted_params
-
)
-
head :ok
-
else
-
render json: { success: false }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def type_matches?
-
['Conversation'].include?(params[:type])
-
end
-
-
def permitted_params
-
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
-
end
-
end
-
class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
-
before_action :inbox, only: [:reauthorize_page]
-
-
def register_facebook_page
-
user_access_token = params[:user_access_token]
-
page_access_token = params[:page_access_token]
-
page_id = params[:page_id]
-
inbox_name = params[:inbox_name]
-
ActiveRecord::Base.transaction do
-
facebook_channel = Current.account.facebook_pages.create!(
-
page_id: page_id, user_access_token: user_access_token,
-
page_access_token: page_access_token
-
)
-
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
-
set_instagram_id(page_access_token, facebook_channel)
-
set_avatar(@facebook_inbox, page_id)
-
end
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
Rails.logger.error "Error in register_facebook_page: #{e.message}"
-
# Additional log statements
-
log_additional_info
-
end
-
-
def log_additional_info
-
Rails.logger.debug do
-
"user_access_token: #{params[:user_access_token]} , page_access_token: #{params[:page_access_token]} ,
-
page_id: #{params[:page_id]}, inbox_name: #{params[:inbox_name]}"
-
end
-
end
-
-
def facebook_pages
-
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
-
end
-
-
def set_instagram_id(page_access_token, facebook_channel)
-
fb_object = Koala::Facebook::API.new(page_access_token)
-
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
-
return if response['instagram_business_account'].blank?
-
-
instagram_id = response['instagram_business_account']['id']
-
facebook_channel.update(instagram_id: instagram_id)
-
rescue StandardError => e
-
Rails.logger.error "Error in set_instagram_id: #{e.message}"
-
end
-
-
# get params[:inbox_id], current_account. params[:omniauth_token]
-
def reauthorize_page
-
if @inbox&.facebook?
-
fb_page_id = @inbox.channel.page_id
-
page_details = fb_object.get_connections('me', 'accounts')
-
-
if (page_detail = (page_details || []).detect { |page| fb_page_id == page['id'] })
-
update_fb_page(fb_page_id, page_detail['access_token'])
-
render and return
-
end
-
end
-
-
head :unprocessable_entity
-
end
-
-
private
-
-
def inbox
-
@inbox = Current.account.inboxes.find_by(id: params[:inbox_id])
-
end
-
-
def update_fb_page(fb_page_id, access_token)
-
fb_page = get_fb_page(fb_page_id)
-
ActiveRecord::Base.transaction do
-
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
-
set_instagram_id(access_token, fb_page)
-
fb_page&.reauthorized!
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
Rails.logger.error "Error in update_fb_page: #{e.message}"
-
end
-
end
-
-
def get_fb_page(fb_page_id)
-
Current.account.facebook_pages.find_by(page_id: fb_page_id)
-
end
-
-
def fb_object
-
@user_access_token = long_lived_token(params[:omniauth_token])
-
Koala::Facebook::API.new(@user_access_token)
-
end
-
-
def long_lived_token(omniauth_token)
-
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
-
koala.exchange_access_token_info(omniauth_token)['access_token']
-
rescue StandardError => e
-
Rails.logger.error "Error in long_lived_token: #{e.message}"
-
end
-
-
def mark_already_existing_facebook_pages(data)
-
return [] if data.empty?
-
-
data.inject([]) do |result, page_detail|
-
page_detail[:exists] = Current.account.facebook_pages.exists?(page_id: page_detail['id'])
-
result << page_detail
-
end
-
end
-
-
def set_avatar(facebook_inbox, page_id)
-
avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large"
-
Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
-
end
-
end
-
class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
-
before_action :campaign, except: [:index, :create]
-
before_action :check_authorization
-
-
def index
-
@campaigns = Current.account.campaigns
-
end
-
-
def show; end
-
-
def create
-
@campaign = Current.account.campaigns.create!(campaign_params)
-
end
-
-
def update
-
@campaign.update!(campaign_params)
-
end
-
-
def destroy
-
@campaign.destroy!
-
head :ok
-
end
-
-
private
-
-
def campaign
-
@campaign ||= Current.account.campaigns.find_by(display_id: params[:id])
-
end
-
-
def campaign_params
-
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
-
:scheduled_at, audience: [:type, :id], trigger_rules: {})
-
end
-
end
-
class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseController
-
before_action :fetch_canned_response, only: [:update, :destroy]
-
-
def index
-
render json: canned_responses
-
end
-
-
def create
-
@canned_response = Current.account.canned_responses.new(canned_response_params)
-
@canned_response.save!
-
render json: @canned_response
-
end
-
-
def update
-
@canned_response.update!(canned_response_params)
-
render json: @canned_response
-
end
-
-
def destroy
-
@canned_response.destroy!
-
head :ok
-
end
-
-
private
-
-
def fetch_canned_response
-
@canned_response = Current.account.canned_responses.find(params[:id])
-
end
-
-
def canned_response_params
-
params.require(:canned_response).permit(:short_code, :content)
-
end
-
-
def canned_responses
-
if params[:search]
-
Current.account.canned_responses
-
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
-
.order_by_search(params[:search])
-
-
else
-
Current.account.canned_responses
-
end
-
end
-
end
-
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
-
before_action :portal
-
before_action :check_authorization
-
before_action :fetch_category, except: [:index, :create]
-
before_action :set_current_page, only: [:index]
-
-
def index
-
@current_locale = params[:locale]
-
@categories = @portal.categories.search(params)
-
end
-
-
def show; end
-
-
def create
-
@category = @portal.categories.create!(category_params)
-
@category.related_categories << related_categories_records
-
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
-
-
@category.save!
-
end
-
-
def update
-
@category.update!(category_params)
-
@category.related_categories = related_categories_records if related_categories_records.any?
-
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
-
-
@category.save!
-
end
-
-
def destroy
-
@category.destroy!
-
head :ok
-
end
-
-
private
-
-
def fetch_category
-
@category = @portal.categories.find(params[:id])
-
end
-
-
def portal
-
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
-
end
-
-
def related_categories_records
-
@portal.categories.where(id: params[:category][:related_category_ids])
-
end
-
-
def category_params
-
params.require(:category).permit(
-
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
-
)
-
end
-
-
def set_current_page
-
@current_page = params[:page] || 1
-
end
-
end
-
class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts::BaseController
-
before_action :authorize_request
-
-
def create
-
process_create
-
rescue StandardError => e
-
render_could_not_create_error(e.message)
-
end
-
-
private
-
-
def authorize_request
-
authorize ::Inbox
-
end
-
-
def process_create
-
ActiveRecord::Base.transaction do
-
authenticate_twilio
-
build_inbox
-
setup_webhooks if @twilio_channel.sms?
-
end
-
end
-
-
def authenticate_twilio
-
client = if permitted_params[:api_key_sid].present?
-
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
-
else
-
Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token])
-
end
-
client.messages.list(limit: 1)
-
end
-
-
def setup_webhooks
-
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
-
end
-
-
def phone_number
-
return if permitted_params[:phone_number].blank?
-
-
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
-
end
-
-
def medium
-
permitted_params[:medium]
-
end
-
-
def build_inbox
-
@twilio_channel = Current.account.twilio_sms.create!(
-
account_sid: permitted_params[:account_sid],
-
auth_token: permitted_params[:auth_token],
-
api_key_sid: permitted_params[:api_key_sid],
-
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
-
phone_number: phone_number,
-
medium: medium
-
)
-
@inbox = Current.account.inboxes.create!(
-
name: permitted_params[:name],
-
channel: @twilio_channel
-
)
-
end
-
-
def permitted_params
-
params.require(:twilio_channel).permit(
-
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
-
)
-
end
-
end
-
class Api::V1::Accounts::ContactInboxesController < Api::V1::Accounts::BaseController
-
before_action :ensure_inbox
-
-
def filter
-
contact_inbox = @inbox.contact_inboxes.where(inbox_id: permitted_params[:inbox_id], source_id: permitted_params[:source_id])
-
return head :not_found if contact_inbox.empty?
-
-
@contact = contact_inbox.first.contact
-
end
-
-
private
-
-
def ensure_inbox
-
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
-
authorize @inbox, :show?
-
end
-
-
def permitted_params
-
params.permit(:inbox_id, :source_id)
-
end
-
end
-
class Api::V1::Accounts::Contacts::BaseController < Api::V1::Accounts::BaseController
-
before_action :ensure_contact
-
-
private
-
-
def ensure_contact
-
@contact = Current.account.contacts.find(params[:contact_id])
-
end
-
end
-
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
-
include HmacConcern
-
before_action :ensure_inbox, only: [:create]
-
-
def create
-
@contact_inbox = ContactInboxBuilder.new(
-
contact: @contact,
-
inbox: @inbox,
-
source_id: params[:source_id],
-
hmac_verified: hmac_verified?
-
).perform
-
end
-
-
private
-
-
def ensure_inbox
-
@inbox = Current.account.inboxes.find(params[:inbox_id])
-
authorize @inbox, :show?
-
end
-
end
-
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
-
def index
-
# Start with all conversations for this contact
-
conversations = Current.account.conversations.includes(
-
:assignee, :contact, :inbox, :taggings
-
).where(contact_id: @contact.id)
-
-
# Apply permission-based filtering using the existing service
-
conversations = Conversations::PermissionFilterService.new(
-
conversations,
-
Current.user,
-
Current.account
-
).perform
-
-
# Only allow conversations from inboxes the user has access to
-
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
-
conversations = conversations.where(inbox_id: inbox_ids)
-
-
@conversations = conversations.order(last_activity_at: :desc).limit(20)
-
end
-
end
-
class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::Contacts::BaseController
-
include LabelConcern
-
-
private
-
-
def model
-
@model ||= @contact
-
end
-
-
def permitted_params
-
params.permit(labels: [])
-
end
-
end
-
class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts::BaseController
-
before_action :note, except: [:index, :create]
-
-
def index
-
@notes = @contact.notes.latest.includes(:user)
-
end
-
-
def show; end
-
-
def create
-
@note = @contact.notes.create!(note_params)
-
end
-
-
def update
-
@note.update(note_params)
-
end
-
-
def destroy
-
@note.destroy!
-
head :ok
-
end
-
-
private
-
-
def note
-
@note ||= @contact.notes.find(params[:id])
-
end
-
-
def note_params
-
params.require(:note).permit(:content).merge({ contact_id: @contact.id, user_id: Current.user.id })
-
end
-
end
-
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
-
include Sift
-
sort_on :email, type: :string
-
sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction]
-
sort_on :phone_number, type: :string
-
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
-
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
-
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
-
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
-
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
-
-
RESULTS_PER_PAGE = 15
-
-
before_action :check_authorization
-
before_action :set_current_page, only: [:index, :active, :search, :filter]
-
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
-
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
-
-
def index
-
@contacts_count = resolved_contacts.count
-
@contacts = fetch_contacts(resolved_contacts)
-
end
-
-
def search
-
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
-
-
contacts = resolved_contacts.where(
-
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
-
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
-
search: "%#{params[:q].strip}%"
-
)
-
@contacts_count = contacts.count
-
@contacts = fetch_contacts(contacts)
-
end
-
-
def import
-
render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank?
-
-
ActiveRecord::Base.transaction do
-
import = Current.account.data_imports.create!(data_type: 'contacts')
-
import.import_file.attach(params[:import_file])
-
end
-
-
head :ok
-
end
-
-
def export
-
column_names = params['column_names']
-
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] }
-
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
-
head :ok, message: I18n.t('errors.contacts.export.success')
-
end
-
-
# returns online contacts
-
def active
-
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
-
.get_available_contact_ids(Current.account.id))
-
@contacts_count = contacts.count
-
@contacts = contacts.page(@current_page)
-
end
-
-
def show; end
-
-
def filter
-
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform
-
contacts = result[:contacts]
-
@contacts_count = result[:count]
-
@contacts = fetch_contacts(contacts)
-
rescue CustomExceptions::CustomFilter::InvalidAttribute,
-
CustomExceptions::CustomFilter::InvalidOperator,
-
CustomExceptions::CustomFilter::InvalidQueryOperator,
-
CustomExceptions::CustomFilter::InvalidValue => e
-
render_could_not_create_error(e.message)
-
end
-
-
def contactable_inboxes
-
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
-
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
-
end
-
-
# TODO : refactor this method into dedicated contacts/custom_attributes controller class and routes
-
def destroy_custom_attributes
-
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
-
@contact.save!
-
end
-
-
def create
-
ActiveRecord::Base.transaction do
-
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
-
@contact.save!
-
@contact_inbox = build_contact_inbox
-
process_avatar_from_url
-
end
-
end
-
-
def update
-
@contact.assign_attributes(contact_update_params)
-
@contact.save!
-
process_avatar_from_url
-
end
-
-
def destroy
-
if ::OnlineStatusTracker.get_presence(
-
@contact.account.id, 'Contact', @contact.id
-
)
-
return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) },
-
:unprocessable_entity)
-
end
-
-
@contact.destroy!
-
head :ok
-
end
-
-
def avatar
-
@contact.avatar.purge if @contact.avatar.attached?
-
@contact
-
end
-
-
private
-
-
# TODO: Move this to a finder class
-
def resolved_contacts
-
return @resolved_contacts if @resolved_contacts
-
-
@resolved_contacts = Current.account.contacts.resolved_contacts
-
-
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
-
@resolved_contacts
-
end
-
-
def set_current_page
-
@current_page = params[:page] || 1
-
end
-
-
def fetch_contacts(contacts)
-
contacts_with_avatar = filtrate(contacts)
-
.includes([{ avatar_attachment: [:blob] }])
-
.page(@current_page).per(RESULTS_PER_PAGE)
-
-
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
-
-
contacts_with_avatar
-
end
-
-
def build_contact_inbox
-
return if params[:inbox_id].blank?
-
-
inbox = Current.account.inboxes.find(params[:inbox_id])
-
ContactInboxBuilder.new(
-
contact: @contact,
-
inbox: inbox,
-
source_id: params[:source_id]
-
).perform
-
end
-
-
def permitted_params
-
params.permit(:name, :identifier, :email, :phone_number, :avatar, :blocked, :avatar_url, additional_attributes: {}, custom_attributes: {})
-
end
-
-
def contact_custom_attributes
-
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
-
-
@contact.custom_attributes
-
end
-
-
def contact_additional_attributes
-
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
-
-
@contact.additional_attributes
-
end
-
-
def contact_update_params
-
permitted_params.except(:custom_attributes, :avatar_url)
-
.merge({ custom_attributes: contact_custom_attributes })
-
.merge({ additional_attributes: contact_additional_attributes })
-
end
-
-
def set_include_contact_inboxes
-
@include_contact_inboxes = if params[:include_contact_inboxes].present?
-
params[:include_contact_inboxes] == 'true'
-
else
-
true
-
end
-
end
-
-
def fetch_contact
-
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
-
end
-
-
def process_avatar_from_url
-
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
-
end
-
-
def render_error(error, error_status)
-
render json: error, status: error_status
-
end
-
end
-
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
-
# assigns agent/team to a conversation
-
def create
-
if params.key?(:assignee_id)
-
set_agent
-
elsif params.key?(:team_id)
-
set_team
-
else
-
render json: nil
-
end
-
end
-
-
private
-
-
def set_agent
-
@agent = Current.account.users.find_by(id: params[:assignee_id])
-
@conversation.assignee = @agent
-
@conversation.save!
-
render_agent
-
end
-
-
def render_agent
-
if @agent.nil?
-
render json: nil
-
else
-
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
-
end
-
end
-
-
def set_team
-
@team = Current.account.teams.find_by(id: params[:team_id])
-
@conversation.update!(team: @team)
-
render json: @team
-
end
-
end
-
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
-
before_action :conversation
-
-
private
-
-
def conversation
-
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
-
authorize @conversation.inbox, :show?
-
end
-
end
-
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
-
include EnsureCurrentAccountHelper
-
before_action :current_account
-
before_action :conversation
-
-
def create
-
return if @conversation.nil? || @current_account.nil?
-
-
super
-
end
-
-
private
-
-
def conversation
-
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
-
end
-
end
-
class Api::V1::Accounts::Conversations::DraftMessagesController < Api::V1::Accounts::Conversations::BaseController
-
def show
-
render json: { has_draft: false } and return unless Redis::Alfred.exists?(draft_redis_key)
-
-
draft_message = Redis::Alfred.get(draft_redis_key)
-
render json: { has_draft: true, message: draft_message }
-
end
-
-
def update
-
Redis::Alfred.set(draft_redis_key, draft_message_params)
-
head :ok
-
end
-
-
def destroy
-
Redis::Alfred.delete(draft_redis_key)
-
head :ok
-
end
-
-
private
-
-
def draft_redis_key
-
format(Redis::Alfred::CONVERSATION_DRAFT_MESSAGE, id: @conversation.id)
-
end
-
-
def draft_message_params
-
params.dig(:draft_message, :message) || ''
-
end
-
end
-
class Api::V1::Accounts::Conversations::LabelsController < Api::V1::Accounts::Conversations::BaseController
-
include LabelConcern
-
-
private
-
-
def model
-
@model ||= @conversation
-
end
-
-
def permitted_params
-
params.permit(:conversation_id, labels: [])
-
end
-
end
-
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
-
before_action :ensure_api_inbox, only: :update
-
-
def index
-
@messages = message_finder.perform
-
end
-
-
def create
-
user = Current.user || @resource
-
mb = Messages::MessageBuilder.new(user, @conversation, params)
-
@message = mb.perform
-
rescue StandardError => e
-
render_could_not_create_error(e.message)
-
end
-
-
def update
-
Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform
-
@message = message
-
end
-
-
def destroy
-
ActiveRecord::Base.transaction do
-
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
-
message.attachments.destroy_all
-
end
-
end
-
-
def retry
-
return if message.blank?
-
-
service = Messages::StatusUpdateService.new(message, 'sent')
-
service.perform
-
message.update!(content_attributes: {})
-
::SendReplyJob.perform_later(message.id)
-
rescue StandardError => e
-
render_could_not_create_error(e.message)
-
end
-
-
def translate
-
return head :ok if already_translated_content_available?
-
-
translated_content = Integrations::GoogleTranslate::ProcessorService.new(
-
message: message,
-
target_language: permitted_params[:target_language]
-
).perform
-
-
if translated_content.present?
-
translations = {}
-
translations[permitted_params[:target_language]] = translated_content
-
translations = message.translations.merge!(translations) if message.translations.present?
-
message.update!(translations: translations)
-
end
-
-
render json: { content: translated_content }
-
end
-
-
private
-
-
def message
-
@message ||= @conversation.messages.find(permitted_params[:id])
-
end
-
-
def message_finder
-
@message_finder ||= MessageFinder.new(@conversation, params)
-
end
-
-
def permitted_params
-
params.permit(:id, :target_language, :status, :external_error)
-
end
-
-
def already_translated_content_available?
-
message.translations.present? && message.translations[permitted_params[:target_language]].present?
-
end
-
-
# API inbox check
-
def ensure_api_inbox
-
# Only API inboxes can update messages
-
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
-
end
-
end
-
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
-
def show
-
@participants = @conversation.conversation_participants
-
end
-
-
def create
-
ActiveRecord::Base.transaction do
-
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
-
end
-
end
-
-
def update
-
ActiveRecord::Base.transaction do
-
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
-
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
-
end
-
@participants = @conversation.conversation_participants
-
render action: 'show'
-
end
-
-
def destroy
-
ActiveRecord::Base.transaction do
-
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
-
end
-
head :ok
-
end
-
-
private
-
-
def participants_to_be_added_ids
-
params[:user_ids] - current_participant_ids
-
end
-
-
def participants_to_be_removed_ids
-
current_participant_ids - params[:user_ids]
-
end
-
-
def current_participant_ids
-
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
-
end
-
end
-
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
-
include Events::Types
-
include DateRangeHelper
-
include HmacConcern
-
-
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
-
before_action :inbox, :contact, :contact_inbox, only: [:create]
-
-
ATTACHMENT_RESULTS_PER_PAGE = 100
-
-
def index
-
result = conversation_finder.perform
-
@conversations = result[:conversations]
-
@conversations_count = result[:count]
-
end
-
-
def meta
-
result = conversation_finder.perform
-
@conversations_count = result[:count]
-
end
-
-
def search
-
result = conversation_finder.perform
-
@conversations = result[:conversations]
-
@conversations_count = result[:count]
-
end
-
-
def attachments
-
@attachments_count = @conversation.attachments.count
-
@attachments = @conversation.attachments
-
.includes(:message)
-
.order(created_at: :desc)
-
.page(attachment_params[:page])
-
.per(ATTACHMENT_RESULTS_PER_PAGE)
-
end
-
-
def show; end
-
-
def create
-
ActiveRecord::Base.transaction do
-
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
-
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
-
end
-
end
-
-
def update
-
@conversation.update!(permitted_update_params)
-
end
-
-
def filter
-
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform
-
@conversations = result[:conversations]
-
@conversations_count = result[:count]
-
rescue CustomExceptions::CustomFilter::InvalidAttribute,
-
CustomExceptions::CustomFilter::InvalidOperator,
-
CustomExceptions::CustomFilter::InvalidQueryOperator,
-
CustomExceptions::CustomFilter::InvalidValue => e
-
render_could_not_create_error(e.message)
-
end
-
-
def mute
-
@conversation.mute!
-
head :ok
-
end
-
-
def unmute
-
@conversation.unmute!
-
head :ok
-
end
-
-
def transcript
-
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
-
-
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
-
head :ok
-
end
-
-
def toggle_status
-
# FIXME: move this logic into a service object
-
if pending_to_open_by_bot?
-
@conversation.bot_handoff!
-
elsif params[:status].present?
-
set_conversation_status
-
@status = @conversation.save!
-
else
-
@status = @conversation.toggle_status
-
end
-
assign_conversation if should_assign_conversation?
-
end
-
-
def pending_to_open_by_bot?
-
return false unless Current.user.is_a?(AgentBot)
-
-
@conversation.status == 'pending' && params[:status] == 'open'
-
end
-
-
def should_assign_conversation?
-
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
-
end
-
-
def toggle_priority
-
@conversation.toggle_priority(params[:priority])
-
head :ok
-
end
-
-
def toggle_typing_status
-
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
-
typing_status_manager.toggle_typing_status
-
head :ok
-
end
-
-
def update_last_seen
-
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
-
end
-
-
def unread
-
last_incoming_message = @conversation.messages.incoming.last
-
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
-
update_last_seen_on_conversation(last_seen_at, true)
-
end
-
-
def custom_attributes
-
@conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes]
-
@conversation.save!
-
end
-
-
private
-
-
def permitted_update_params
-
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
-
params.permit(:priority)
-
end
-
-
def attachment_params
-
params.permit(:page)
-
end
-
-
def update_last_seen_on_conversation(last_seen_at, update_assignee)
-
# rubocop:disable Rails/SkipsModelValidations
-
@conversation.update_column(:agent_last_seen_at, last_seen_at)
-
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
-
def set_conversation_status
-
@conversation.status = params[:status]
-
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
-
end
-
-
def assign_conversation
-
@conversation.assignee = current_user
-
@conversation.save!
-
end
-
-
def conversation
-
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
-
authorize @conversation.inbox, :show?
-
end
-
-
def inbox
-
return if params[:inbox_id].blank?
-
-
@inbox = Current.account.inboxes.find(params[:inbox_id])
-
authorize @inbox, :show?
-
end
-
-
def contact
-
return if params[:contact_id].blank?
-
-
@contact = Current.account.contacts.find(params[:contact_id])
-
end
-
-
def contact_inbox
-
@contact_inbox = build_contact_inbox
-
-
# fallback for the old case where we do look up only using source id
-
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
-
# and deprecate the support of passing only source_id as the param
-
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
-
authorize @contact_inbox.inbox, :show?
-
rescue ActiveRecord::RecordNotUnique
-
render json: { error: 'source_id should be unique' }, status: :unprocessable_entity
-
end
-
-
def build_contact_inbox
-
return if @inbox.blank? || @contact.blank?
-
-
ContactInboxBuilder.new(
-
contact: @contact,
-
inbox: @inbox,
-
source_id: params[:source_id],
-
hmac_verified: hmac_verified?
-
).perform
-
end
-
-
def conversation_finder
-
@conversation_finder ||= ConversationFinder.new(Current.user, params)
-
end
-
-
def assignee?
-
@conversation.assignee_id? && Current.user == @conversation.assignee
-
end
-
end
-
-
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
-
class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::BaseController
-
include Sift
-
include DateRangeHelper
-
-
RESULTS_PER_PAGE = 25
-
-
before_action :check_authorization
-
before_action :set_csat_survey_responses, only: [:index, :metrics, :download]
-
before_action :set_current_page, only: [:index]
-
before_action :set_current_page_surveys, only: [:index]
-
before_action :set_total_sent_messages_count, only: [:metrics]
-
-
sort_on :created_at, type: :datetime
-
-
def index; end
-
-
def metrics
-
@total_count = @csat_survey_responses.count
-
@ratings_count = @csat_survey_responses.group(:rating).count
-
end
-
-
def download
-
response.headers['Content-Type'] = 'text/csv'
-
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
-
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download', formats: [:csv]
-
end
-
-
private
-
-
def set_total_sent_messages_count
-
@csat_messages = Current.account.messages.input_csat
-
@csat_messages = @csat_messages.where(created_at: range) if range.present?
-
@total_sent_messages_count = @csat_messages.count
-
end
-
-
def set_csat_survey_responses
-
base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
-
@csat_survey_responses = filtrate(base_query).filter_by_created_at(range)
-
.filter_by_assigned_agent_id(params[:user_ids])
-
.filter_by_inbox_id(params[:inbox_id])
-
.filter_by_team_id(params[:team_id])
-
.filter_by_rating(params[:rating])
-
end
-
-
def set_current_page_surveys
-
@csat_survey_responses = @csat_survey_responses.page(@current_page).per(RESULTS_PER_PAGE)
-
end
-
-
def set_current_page
-
@current_page = params[:page] || 1
-
end
-
end
-
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
-
before_action :fetch_custom_attributes_definitions, except: [:create]
-
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
-
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
-
-
def index; end
-
-
def show; end
-
-
def create
-
@custom_attribute_definition = Current.account.custom_attribute_definitions.create!(
-
permitted_payload
-
)
-
end
-
-
def update
-
@custom_attribute_definition.update!(permitted_payload)
-
end
-
-
def destroy
-
@custom_attribute_definition.destroy!
-
head :no_content
-
end
-
-
private
-
-
def fetch_custom_attributes_definitions
-
@custom_attribute_definitions = Current.account.custom_attribute_definitions.with_attribute_model(permitted_params[:attribute_model])
-
end
-
-
def fetch_custom_attribute_definition
-
@custom_attribute_definition = Current.account.custom_attribute_definitions.find(permitted_params[:id])
-
end
-
-
def permitted_payload
-
params.require(:custom_attribute_definition).permit(
-
:attribute_display_name,
-
:attribute_description,
-
:attribute_display_type,
-
:attribute_key,
-
:attribute_model,
-
:regex_pattern,
-
:regex_cue,
-
attribute_values: []
-
)
-
end
-
-
def permitted_params
-
params.permit(:id, :filter_type, :attribute_model)
-
end
-
end
-
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
-
before_action :check_authorization
-
before_action :fetch_custom_filters, except: [:create]
-
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
-
DEFAULT_FILTER_TYPE = 'conversation'.freeze
-
-
def index; end
-
-
def show; end
-
-
def create
-
@custom_filter = current_user.custom_filters.create!(
-
permitted_payload.merge(account_id: Current.account.id)
-
)
-
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
-
end
-
-
def update
-
@custom_filter.update!(permitted_payload)
-
end
-
-
def destroy
-
@custom_filter.destroy!
-
head :no_content
-
end
-
-
private
-
-
def fetch_custom_filters
-
@custom_filters = current_user.custom_filters.where(
-
account_id: Current.account.id,
-
filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE
-
)
-
end
-
-
def fetch_custom_filter
-
@custom_filter = @custom_filters.find(permitted_params[:id])
-
end
-
-
def permitted_payload
-
params.require(:custom_filter).permit(
-
:name,
-
:filter_type,
-
query: {}
-
)
-
end
-
-
def permitted_params
-
params.permit(:id, :filter_type)
-
end
-
end
-
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
-
before_action :fetch_dashboard_apps, except: [:create]
-
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
-
-
def index; end
-
-
def show; end
-
-
def create
-
@dashboard_app = Current.account.dashboard_apps.create!(
-
permitted_payload.merge(user_id: Current.user.id)
-
)
-
end
-
-
def update
-
@dashboard_app.update!(permitted_payload)
-
end
-
-
def destroy
-
@dashboard_app.destroy!
-
head :no_content
-
end
-
-
private
-
-
def fetch_dashboard_apps
-
@dashboard_apps = Current.account.dashboard_apps
-
end
-
-
def fetch_dashboard_app
-
@dashboard_app = @dashboard_apps.find(permitted_params[:id])
-
end
-
-
def permitted_payload
-
params.require(:dashboard_app).permit(
-
:title,
-
content: [:url, :type]
-
)
-
end
-
-
def permitted_params
-
params.permit(:id)
-
end
-
end
-
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
-
include GoogleConcern
-
before_action :check_authorization
-
-
def create
-
email = params[:authorization][:email]
-
redirect_url = google_client.auth_code.authorize_url(
-
{
-
redirect_uri: "#{base_url}/google/callback",
-
scope: 'email profile https://mail.google.com/',
-
response_type: 'code',
-
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
-
access_type: 'offline', # the default is 'online'
-
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
-
}
-
)
-
-
if redirect_url
-
cache_key = "google::#{email.downcase}"
-
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
-
render json: { success: true, url: redirect_url }
-
else
-
render json: { success: false }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def check_authorization
-
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
-
end
-
end
-
class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController
-
before_action :fetch_inbox
-
before_action :current_agents_ids, only: [:create, :update]
-
-
def show
-
authorize @inbox, :show?
-
fetch_updated_agents
-
end
-
-
def create
-
authorize @inbox, :create?
-
ActiveRecord::Base.transaction do
-
@inbox.add_members(agents_to_be_added_ids)
-
end
-
fetch_updated_agents
-
end
-
-
def update
-
authorize @inbox, :update?
-
update_agents_list
-
fetch_updated_agents
-
end
-
-
def destroy
-
authorize @inbox, :destroy?
-
ActiveRecord::Base.transaction do
-
@inbox.remove_members(params[:user_ids])
-
end
-
head :ok
-
end
-
-
private
-
-
def fetch_updated_agents
-
@agents = Current.account.users.where(id: @inbox.members.select(:user_id))
-
end
-
-
def update_agents_list
-
# get all the user_ids which the inbox currently has as members.
-
# get the list of user_ids from params
-
# the missing ones are the agents which are to be deleted from the inbox
-
# the new ones are the agents which are to be added to the inbox
-
ActiveRecord::Base.transaction do
-
@inbox.add_members(agents_to_be_added_ids)
-
@inbox.remove_members(agents_to_be_removed_ids)
-
end
-
end
-
-
def agents_to_be_added_ids
-
params[:user_ids] - @current_agents_ids
-
end
-
-
def agents_to_be_removed_ids
-
@current_agents_ids - params[:user_ids]
-
end
-
-
def current_agents_ids
-
@current_agents_ids = @inbox.members.pluck(:id)
-
end
-
-
def fetch_inbox
-
@inbox = Current.account.inboxes.find(params[:inbox_id])
-
end
-
end
-
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
-
include Api::V1::InboxesHelper
-
before_action :fetch_inbox, except: [:index, :create]
-
before_action :fetch_agent_bot, only: [:set_agent_bot]
-
before_action :validate_limit, only: [:create]
-
# we are already handling the authorization in fetch inbox
-
before_action :check_authorization, except: [:show]
-
-
def index
-
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
-
end
-
-
def show; end
-
-
# Deprecated: This API will be removed in 2.7.0
-
def assignable_agents
-
@assignable_agents = @inbox.assignable_agents
-
end
-
-
def campaigns
-
@campaigns = @inbox.campaigns
-
end
-
-
def avatar
-
@inbox.avatar.attachment.destroy! if @inbox.avatar.attached?
-
head :ok
-
end
-
-
def create
-
ActiveRecord::Base.transaction do
-
channel = create_channel
-
@inbox = Current.account.inboxes.build(
-
{
-
name: inbox_name(channel),
-
channel: channel
-
}.merge(
-
permitted_params.except(:channel)
-
)
-
)
-
@inbox.save!
-
end
-
end
-
-
def update
-
@inbox.update!(permitted_params.except(:channel))
-
update_inbox_working_hours
-
update_channel if channel_update_required?
-
end
-
-
def agent_bot
-
@agent_bot = @inbox.agent_bot
-
end
-
-
def set_agent_bot
-
if @agent_bot
-
agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox)
-
agent_bot_inbox.agent_bot = @agent_bot
-
agent_bot_inbox.save!
-
elsif @inbox.agent_bot_inbox.present?
-
@inbox.agent_bot_inbox.destroy!
-
end
-
head :ok
-
end
-
-
def destroy
-
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
-
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
-
end
-
-
private
-
-
def fetch_inbox
-
@inbox = Current.account.inboxes.find(params[:id])
-
authorize @inbox, :show?
-
end
-
-
def fetch_agent_bot
-
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
-
end
-
-
def create_channel
-
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
-
-
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
-
end
-
-
def update_inbox_working_hours
-
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
-
end
-
-
def update_channel
-
channel_attributes = get_channel_attributes(@inbox.channel_type)
-
return if permitted_params(channel_attributes)[:channel].blank?
-
-
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
-
-
reauthorize_and_update_channel(channel_attributes)
-
update_channel_feature_flags
-
end
-
-
def channel_update_required?
-
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
-
end
-
-
def validate_and_update_email_channel(channel_attributes)
-
validate_email_channel(channel_attributes)
-
rescue StandardError => e
-
render json: { message: e }, status: :unprocessable_entity and return
-
end
-
-
def reauthorize_and_update_channel(channel_attributes)
-
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
-
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
-
end
-
-
def update_channel_feature_flags
-
return unless @inbox.web_widget?
-
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
-
-
@inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags]
-
@inbox.channel.save!
-
end
-
-
def inbox_attributes
-
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
-
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
-
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name]
-
end
-
-
def permitted_params(channel_attributes = [])
-
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
-
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
-
-
params.permit(
-
*inbox_attributes,
-
channel: [:type, *channel_attributes]
-
)
-
end
-
-
def channel_type_from_params
-
{
-
'web_widget' => Channel::WebWidget,
-
'api' => Channel::Api,
-
'email' => Channel::Email,
-
'line' => Channel::Line,
-
'telegram' => Channel::Telegram,
-
'whatsapp' => Channel::Whatsapp,
-
'sms' => Channel::Sms
-
}[permitted_params[:channel][:type]]
-
end
-
-
def get_channel_attributes(channel_type)
-
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
-
channel_type.constantize::EDITABLE_ATTRS.presence
-
else
-
[]
-
end
-
end
-
end
-
-
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
-
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
-
include InstagramConcern
-
include Instagram::IntegrationHelper
-
before_action :check_authorization
-
-
def create
-
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
-
redirect_url = instagram_client.auth_code.authorize_url(
-
{
-
redirect_uri: "#{base_url}/instagram/callback",
-
scope: REQUIRED_SCOPES.join(','),
-
enable_fb_login: '0',
-
force_authentication: '1',
-
response_type: 'code',
-
state: generate_instagram_token(Current.account.id)
-
}
-
)
-
if redirect_url
-
render json: { success: true, url: redirect_url }
-
else
-
render json: { success: false }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def check_authorization
-
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
-
end
-
end
-
class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController
-
before_action :check_admin_authorization?, except: [:index, :show]
-
before_action :fetch_apps, only: [:index]
-
before_action :fetch_app, only: [:show]
-
-
def index; end
-
-
def show; end
-
-
private
-
-
def fetch_apps
-
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
-
end
-
-
def fetch_app
-
@app = Integrations::App.find(id: params[:id])
-
end
-
end
-
class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseController
-
before_action :fetch_conversation, only: [:create_a_meeting]
-
before_action :fetch_message, only: [:add_participant_to_meeting]
-
before_action :authorize_request
-
-
def create_a_meeting
-
render_response(dyte_processor_service.create_a_meeting(Current.user))
-
end
-
-
def add_participant_to_meeting
-
if @message.content_type != 'integrations'
-
return render json: {
-
error: I18n.t('errors.dyte.invalid_message_type')
-
}, status: :unprocessable_entity
-
end
-
-
render_response(
-
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
-
)
-
end
-
-
private
-
-
def authorize_request
-
authorize @conversation.inbox, :show?
-
end
-
-
def render_response(response)
-
render json: response, status: response[:error].blank? ? :ok : :unprocessable_entity
-
end
-
-
def dyte_processor_service
-
Integrations::Dyte::ProcessorService.new(account: Current.account, conversation: @conversation)
-
end
-
-
def permitted_params
-
params.permit(:conversation_id, :message_id)
-
end
-
-
def fetch_conversation
-
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
-
end
-
-
def fetch_message
-
@message = Current.account.messages.find(permitted_params[:message_id])
-
@conversation = @message.conversation
-
end
-
end
-
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
-
before_action :fetch_hook, except: [:create]
-
before_action :check_authorization
-
-
def create
-
@hook = Current.account.hooks.create!(permitted_params)
-
end
-
-
def update
-
@hook.update!(permitted_params.slice(:status, :settings))
-
end
-
-
def process_event
-
response = @hook.process_event(params[:event])
-
-
# for cases like an invalid event, or when conversation does not have enough messages
-
# for a label suggestion, the response is nil
-
if response.nil?
-
render json: { message: nil }
-
elsif response[:error]
-
render json: { error: response[:error] }, status: :unprocessable_entity
-
else
-
render json: { message: response[:message] }
-
end
-
end
-
-
def destroy
-
@hook.destroy!
-
head :ok
-
end
-
-
private
-
-
def fetch_hook
-
@hook = Current.account.hooks.find(params[:id])
-
end
-
-
def check_authorization
-
authorize(:hook)
-
end
-
-
def permitted_params
-
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
-
end
-
end
-
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
-
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
-
before_action :fetch_hook, only: [:destroy]
-
-
def destroy
-
@hook.destroy!
-
head :ok
-
end
-
-
def teams
-
teams = linear_processor_service.teams
-
if teams[:error]
-
render json: { error: teams[:error] }, status: :unprocessable_entity
-
else
-
render json: teams[:data], status: :ok
-
end
-
end
-
-
def team_entities
-
team_id = permitted_params[:team_id]
-
team_entities = linear_processor_service.team_entities(team_id)
-
if team_entities[:error]
-
render json: { error: team_entities[:error] }, status: :unprocessable_entity
-
else
-
render json: team_entities[:data], status: :ok
-
end
-
end
-
-
def create_issue
-
issue = linear_processor_service.create_issue(permitted_params)
-
if issue[:error]
-
render json: { error: issue[:error] }, status: :unprocessable_entity
-
else
-
render json: issue[:data], status: :ok
-
end
-
end
-
-
def link_issue
-
issue_id = permitted_params[:issue_id]
-
title = permitted_params[:title]
-
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
-
if issue[:error]
-
render json: { error: issue[:error] }, status: :unprocessable_entity
-
else
-
render json: issue[:data], status: :ok
-
end
-
end
-
-
def unlink_issue
-
link_id = permitted_params[:link_id]
-
issue = linear_processor_service.unlink_issue(link_id)
-
-
if issue[:error]
-
render json: { error: issue[:error] }, status: :unprocessable_entity
-
else
-
render json: issue[:data], status: :ok
-
end
-
end
-
-
def linked_issues
-
issues = linear_processor_service.linked_issues(conversation_link)
-
-
if issues[:error]
-
render json: { error: issues[:error] }, status: :unprocessable_entity
-
else
-
render json: issues[:data], status: :ok
-
end
-
end
-
-
def search_issue
-
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
-
-
term = params[:q]
-
issues = linear_processor_service.search_issue(term)
-
if issues[:error]
-
render json: { error: issues[:error] }, status: :unprocessable_entity
-
else
-
render json: issues[:data], status: :ok
-
end
-
end
-
-
private
-
-
def conversation_link
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
-
end
-
-
def fetch_conversation
-
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
-
end
-
-
def linear_processor_service
-
Integrations::Linear::ProcessorService.new(account: Current.account)
-
end
-
-
def permitted_params
-
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
-
end
-
-
def fetch_hook
-
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
-
end
-
end
-
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
-
include Shopify::IntegrationHelper
-
before_action :setup_shopify_context, only: [:orders]
-
before_action :fetch_hook, except: [:auth]
-
before_action :validate_contact, only: [:orders]
-
-
def auth
-
shop_domain = params[:shop_domain]
-
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
-
-
state = generate_shopify_token(Current.account.id)
-
-
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
-
auth_url += URI.encode_www_form(
-
client_id: client_id,
-
scope: REQUIRED_SCOPES.join(','),
-
redirect_uri: redirect_uri,
-
state: state
-
)
-
-
render json: { redirect_url: auth_url }
-
end
-
-
def orders
-
customers = fetch_customers
-
return render json: { orders: [] } if customers.empty?
-
-
orders = fetch_orders(customers.first['id'])
-
render json: { orders: orders }
-
rescue ShopifyAPI::Errors::HttpResponseError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
def destroy
-
@hook.destroy!
-
head :ok
-
rescue StandardError => e
-
render json: { error: e.message }, status: :unprocessable_entity
-
end
-
-
private
-
-
def redirect_uri
-
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
-
end
-
-
def contact
-
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
-
end
-
-
def fetch_hook
-
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
-
end
-
-
def fetch_customers
-
query = []
-
query << "email:#{contact.email}" if contact.email.present?
-
query << "phone:#{contact.phone_number}" if contact.phone_number.present?
-
-
shopify_client.get(
-
path: 'customers/search.json',
-
query: {
-
query: query.join(' OR '),
-
fields: 'id,email,phone'
-
}
-
).body['customers'] || []
-
end
-
-
def fetch_orders(customer_id)
-
orders = shopify_client.get(
-
path: 'orders.json',
-
query: {
-
customer_id: customer_id,
-
status: 'any',
-
fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
-
}
-
).body['orders'] || []
-
-
orders.map do |order|
-
order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}")
-
end
-
end
-
-
def setup_shopify_context
-
return if client_id.blank? || client_secret.blank?
-
-
ShopifyAPI::Context.setup(
-
api_key: client_id,
-
api_secret_key: client_secret,
-
api_version: '2025-01'.freeze,
-
scope: REQUIRED_SCOPES.join(','),
-
is_embedded: true,
-
is_private: false
-
)
-
end
-
-
def shopify_session
-
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
-
end
-
-
def shopify_client
-
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
-
end
-
-
def validate_contact
-
return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?)
-
-
render json: { error: 'Contact information missing' },
-
status: :unprocessable_entity
-
end
-
end
-
class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController
-
before_action :check_admin_authorization?
-
before_action :fetch_hook, only: [:update, :destroy, :list_all_channels]
-
-
def list_all_channels
-
@channels = channel_builder.fetch_channels
-
end
-
-
def create
-
hook_builder = Integrations::Slack::HookBuilder.new(
-
account: Current.account,
-
code: params[:code],
-
inbox_id: params[:inbox_id]
-
)
-
@hook = hook_builder.perform
-
end
-
-
def update
-
@hook = channel_builder.update(permitted_params[:reference_id])
-
render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank?
-
end
-
-
def destroy
-
@hook.destroy!
-
head :ok
-
end
-
-
private
-
-
def fetch_hook
-
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'slack')
-
end
-
-
def channel_builder
-
Integrations::Slack::ChannelBuilder.new(hook: @hook)
-
end
-
-
def permitted_params
-
params.permit(:reference_id)
-
end
-
end
-
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
-
before_action :current_account
-
before_action :fetch_label, except: [:index, :create]
-
before_action :check_authorization
-
-
def index
-
@labels = policy_scope(Current.account.labels)
-
end
-
-
def show; end
-
-
def create
-
@label = Current.account.labels.create!(permitted_params)
-
end
-
-
def update
-
@label.update!(permitted_params)
-
end
-
-
def destroy
-
@label.destroy!
-
head :ok
-
end
-
-
private
-
-
def fetch_label
-
@label = Current.account.labels.find(params[:id])
-
end
-
-
def permitted_params
-
params.require(:label).permit(:title, :description, :color, :show_on_sidebar)
-
end
-
end
-
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
-
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
-
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
-
-
def index
-
@macros = Macro.with_visibility(current_user, params)
-
end
-
-
def show
-
head :not_found if @macro.nil?
-
end
-
-
def create
-
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
-
@macro.set_visibility(current_user, permitted_params)
-
@macro.actions = params[:actions]
-
-
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
-
-
@macro.save!
-
process_attachments
-
@macro
-
end
-
-
def update
-
ActiveRecord::Base.transaction do
-
@macro.update!(macros_with_user)
-
@macro.set_visibility(current_user, permitted_params)
-
process_attachments
-
@macro.save!
-
rescue StandardError => e
-
Rails.logger.error e
-
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
-
end
-
end
-
-
def destroy
-
@macro.destroy!
-
head :ok
-
end
-
-
def execute
-
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
-
-
head :ok
-
end
-
-
private
-
-
def process_attachments
-
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
-
return if actions.blank?
-
-
actions.each do |action|
-
blob_id = action['action_params']
-
blob = ActiveStorage::Blob.find_by(id: blob_id)
-
@macro.files.attach(blob)
-
end
-
end
-
-
def permitted_params
-
params.permit(
-
:name, :account_id, :visibility,
-
actions: [:action_name, { action_params: [] }]
-
)
-
end
-
-
def macros_with_user
-
permitted_params.merge(updated_by_id: current_user.id)
-
end
-
-
def fetch_macro
-
@macro = Current.account.macros.find_by(id: params[:id])
-
end
-
-
def check_authorization
-
authorize(@macro) if @macro.present?
-
end
-
end
-
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
-
include MicrosoftConcern
-
before_action :check_authorization
-
-
def create
-
email = params[:authorization][:email]
-
redirect_url = microsoft_client.auth_code.authorize_url(
-
{
-
redirect_uri: "#{base_url}/microsoft/callback",
-
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
-
prompt: 'consent'
-
}
-
)
-
if redirect_url
-
cache_key = "microsoft::#{email.downcase}"
-
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
-
render json: { success: true, url: redirect_url }
-
else
-
render json: { success: false }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def check_authorization
-
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
-
end
-
end
-
class Api::V1::Accounts::NotificationSettingsController < Api::V1::Accounts::BaseController
-
before_action :set_user, :load_notification_setting
-
-
def show; end
-
-
def update
-
update_flags
-
@notification_setting.save!
-
render action: 'show'
-
end
-
-
private
-
-
def set_user
-
@user = current_user
-
end
-
-
def load_notification_setting
-
@notification_setting = @user.notification_settings.find_by(account_id: Current.account.id)
-
end
-
-
def notification_setting_params
-
params.require(:notification_settings).permit(selected_email_flags: [], selected_push_flags: [])
-
end
-
-
def update_flags
-
@notification_setting.selected_email_flags = notification_setting_params[:selected_email_flags]
-
@notification_setting.selected_push_flags = notification_setting_params[:selected_push_flags]
-
end
-
end
-
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
-
RESULTS_PER_PAGE = 15
-
include DateRangeHelper
-
-
before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread]
-
before_action :set_primary_actor, only: [:read_all]
-
before_action :set_current_page, only: [:index]
-
-
def index
-
@notifications = notification_finder.notifications
-
@unread_count = notification_finder.unread_count
-
@count = notification_finder.count
-
end
-
-
def read_all
-
# rubocop:disable Rails/SkipsModelValidations
-
if @primary_actor
-
current_user.notifications.where(account_id: current_account.id, primary_actor: @primary_actor, read_at: nil)
-
.update_all(read_at: DateTime.now.utc)
-
else
-
current_user.notifications.where(account_id: current_account.id, read_at: nil).update_all(read_at: DateTime.now.utc)
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
head :ok
-
end
-
-
def update
-
@notification.update(read_at: DateTime.now.utc)
-
render json: @notification
-
end
-
-
def unread
-
@notification.update(read_at: nil)
-
render json: @notification
-
end
-
-
def destroy
-
@notification.destroy
-
head :ok
-
end
-
-
def destroy_all
-
if params[:type] == 'read'
-
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
-
else
-
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
-
end
-
head :ok
-
end
-
-
def unread_count
-
@unread_count = notification_finder.unread_count
-
render json: @unread_count
-
end
-
-
def snooze
-
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
-
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
-
render json: @notification
-
end
-
-
private
-
-
def set_primary_actor
-
return unless params[:primary_actor_type]
-
return unless Notification::PRIMARY_ACTORS.include?(params[:primary_actor_type])
-
-
@primary_actor = params[:primary_actor_type].safe_constantize.find_by(id: params[:primary_actor_id])
-
end
-
-
def fetch_notification
-
@notification = current_user.notifications.find(params[:id])
-
end
-
-
def set_current_page
-
@current_page = params[:page] || 1
-
end
-
-
def notification_finder
-
@notification_finder ||= NotificationFinder.new(Current.user, Current.account, params)
-
end
-
end
-
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
-
include ::FileTypeHelper
-
-
before_action :fetch_portal, except: [:index, :create]
-
before_action :check_authorization
-
before_action :set_current_page, only: [:index]
-
-
def index
-
@portals = Current.account.portals
-
end
-
-
def show
-
@all_articles = @portal.articles
-
@articles = @all_articles.search(locale: params[:locale])
-
end
-
-
def create
-
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
-
@portal.custom_domain = parsed_custom_domain
-
@portal.save!
-
process_attached_logo
-
end
-
-
def update
-
ActiveRecord::Base.transaction do
-
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
-
# @portal.custom_domain = parsed_custom_domain
-
process_attached_logo if params[:blob_id].present?
-
rescue StandardError => e
-
Rails.logger.error e
-
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
-
end
-
end
-
-
def destroy
-
@portal.destroy!
-
head :ok
-
end
-
-
def archive
-
@portal.update(archive: true)
-
head :ok
-
end
-
-
def logo
-
@portal.logo.purge if @portal.logo.attached?
-
head :ok
-
end
-
-
def process_attached_logo
-
blob_id = params[:blob_id]
-
blob = ActiveStorage::Blob.find_by(id: blob_id)
-
@portal.logo.attach(blob)
-
end
-
-
private
-
-
def fetch_portal
-
@portal = Current.account.portals.find_by(slug: permitted_params[:id])
-
end
-
-
def permitted_params
-
params.permit(:id)
-
end
-
-
def portal_params
-
params.require(:portal).permit(
-
:account_id, :color, :custom_domain, :header_text, :homepage_link,
-
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
-
)
-
end
-
-
def live_chat_widget_params
-
permitted_params = params.permit(:inbox_id)
-
return {} if permitted_params[:inbox_id].blank?
-
-
inbox = Inbox.find(permitted_params[:inbox_id])
-
return {} unless inbox.web_widget?
-
-
{ channel_web_widget_id: inbox.channel.id }
-
end
-
-
def set_current_page
-
@current_page = params[:page] || 1
-
end
-
-
def parsed_custom_domain
-
domain = URI.parse(@portal.custom_domain)
-
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
-
end
-
end
-
class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
-
def index
-
@result = search('all')
-
end
-
-
def conversations
-
@result = search('Conversation')
-
end
-
-
def contacts
-
@result = search('Contact')
-
end
-
-
def messages
-
@result = search('Message')
-
end
-
-
private
-
-
def search(search_type)
-
SearchService.new(
-
current_user: Current.user,
-
current_account: Current.account,
-
search_type: search_type,
-
params: params
-
).perform
-
end
-
end
-
class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseController
-
before_action :fetch_team
-
before_action :check_authorization
-
before_action :validate_member_id_params, only: [:create, :update, :destroy]
-
-
def index
-
@team_members = @team.team_members.map(&:user)
-
end
-
-
def create
-
ActiveRecord::Base.transaction do
-
@team_members = @team.add_members(members_to_be_added_ids)
-
end
-
end
-
-
def update
-
ActiveRecord::Base.transaction do
-
@team.add_members(members_to_be_added_ids)
-
@team.remove_members(members_to_be_removed_ids)
-
end
-
@team_members = @team.members
-
render action: 'create'
-
end
-
-
def destroy
-
ActiveRecord::Base.transaction do
-
@team.remove_members(params[:user_ids])
-
end
-
head :ok
-
end
-
-
private
-
-
def members_to_be_added_ids
-
params[:user_ids] - current_members_ids
-
end
-
-
def members_to_be_removed_ids
-
current_members_ids - params[:user_ids]
-
end
-
-
def current_members_ids
-
@current_members_ids ||= @team.members.pluck(:id)
-
end
-
-
def fetch_team
-
@team = Current.account.teams.find(params[:team_id])
-
end
-
-
def validate_member_id_params
-
invalid_ids = params[:user_ids].map(&:to_i) - @team.account.user_ids
-
-
render json: { error: 'Invalid User IDs' }, status: :unauthorized and return if invalid_ids.present?
-
end
-
end
-
class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
-
before_action :fetch_team, only: [:show, :update, :destroy]
-
before_action :check_authorization
-
-
def index
-
@teams = Current.account.teams
-
end
-
-
def show; end
-
-
def create
-
@team = Current.account.teams.new(team_params)
-
@team.save!
-
end
-
-
def update
-
@team.update!(team_params)
-
end
-
-
def destroy
-
@team.destroy!
-
head :ok
-
end
-
-
private
-
-
def fetch_team
-
@team = Current.account.teams.find(params[:id])
-
end
-
-
def team_params
-
params.require(:team).permit(:name, :description, :allow_auto_assign)
-
end
-
end
-
class Api::V1::Accounts::Twitter::AuthorizationsController < Api::V1::Accounts::BaseController
-
include TwitterConcern
-
-
before_action :check_authorization
-
-
def create
-
@response = twitter_client.request_oauth_token(url: twitter_callback_url)
-
if @response.status == '200'
-
::Redis::Alfred.setex(oauth_token, Current.account.id)
-
render json: { success: true, url: oauth_authorize_endpoint(oauth_token) }
-
else
-
render json: { success: false }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def oauth_token
-
parsed_body['oauth_token']
-
end
-
-
def oauth_authorize_endpoint(oauth_token)
-
"#{twitter_api_base_url}/oauth/authorize?oauth_token=#{oauth_token}"
-
end
-
-
def check_authorization
-
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
-
end
-
end
-
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
-
def create
-
result = if params[:attachment].present?
-
create_from_file
-
elsif params[:external_url].present?
-
create_from_url
-
else
-
render_error('No file or URL provided', :unprocessable_entity)
-
end
-
-
render_success(result) if result.is_a?(ActiveStorage::Blob)
-
end
-
-
private
-
-
def create_from_file
-
attachment = params[:attachment]
-
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
-
end
-
-
def create_from_url
-
uri = parse_uri(params[:external_url])
-
return if performed?
-
-
fetch_and_process_file_from_uri(uri)
-
end
-
-
def parse_uri(url)
-
uri = URI.parse(url)
-
validate_uri(uri)
-
uri
-
rescue URI::InvalidURIError, SocketError
-
render_error('Invalid URL provided', :unprocessable_entity)
-
nil
-
end
-
-
def validate_uri(uri)
-
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
-
end
-
-
def fetch_and_process_file_from_uri(uri)
-
uri.open do |file|
-
create_and_save_blob(file, File.basename(uri.path), file.content_type)
-
end
-
rescue OpenURI::HTTPError => e
-
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
-
rescue SocketError
-
render_error('Invalid URL provided', :unprocessable_entity)
-
rescue StandardError
-
render_error('An unexpected error occurred', :internal_server_error)
-
end
-
-
def create_and_save_blob(io, filename, content_type)
-
ActiveStorage::Blob.create_and_upload!(
-
io: io,
-
filename: filename,
-
content_type: content_type
-
)
-
end
-
-
def render_success(file_blob)
-
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
-
end
-
-
def render_error(message, status)
-
render json: { error: message }, status: status
-
end
-
end
-
class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
-
before_action :check_authorization
-
before_action :fetch_webhook, only: [:update, :destroy]
-
-
def index
-
@webhooks = Current.account.webhooks
-
end
-
-
def create
-
@webhook = Current.account.webhooks.new(webhook_params)
-
@webhook.save!
-
end
-
-
def update
-
@webhook.update!(webhook_params)
-
end
-
-
def destroy
-
@webhook.destroy!
-
head :ok
-
end
-
-
private
-
-
def webhook_params
-
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
-
end
-
-
def fetch_webhook
-
@webhook = Current.account.webhooks.find(params[:id])
-
end
-
end
-
class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
-
before_action :check_authorization
-
before_action :fetch_webhook, only: [:update]
-
-
def update
-
@working_hour.update!(working_hour_params)
-
end
-
-
private
-
-
def working_hour_params
-
params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
-
end
-
-
def fetch_working_hour
-
@working_hour = Current.account.working_hours.find(params[:id])
-
end
-
end
-
class Api::V1::AccountsController < Api::BaseController
-
include AuthHelper
-
include CacheKeysHelper
-
-
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
-
only: [:create], raise: false
-
before_action :check_signup_enabled, only: [:create]
-
before_action :ensure_account_name, only: [:create]
-
before_action :validate_captcha, only: [:create]
-
before_action :fetch_account, except: [:create]
-
before_action :check_authorization, except: [:create]
-
-
rescue_from CustomExceptions::Account::InvalidEmail,
-
CustomExceptions::Account::InvalidParams,
-
CustomExceptions::Account::UserExists,
-
CustomExceptions::Account::UserErrors,
-
with: :render_error_response
-
-
def show
-
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
-
render 'api/v1/accounts/show', format: :json
-
end
-
-
def create
-
@user, @account = AccountBuilder.new(
-
account_name: account_params[:account_name],
-
user_full_name: account_params[:user_full_name],
-
email: account_params[:email],
-
user_password: account_params[:password],
-
locale: account_params[:locale],
-
user: current_user
-
).perform
-
if @user
-
send_auth_headers(@user)
-
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
-
else
-
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
-
end
-
end
-
-
def cache_keys
-
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
-
render json: { cache_keys: cache_keys_for_account }, status: :ok
-
end
-
-
def update
-
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
-
@account.custom_attributes.merge!(custom_attributes_params)
-
@account.settings.merge!(settings_params)
-
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
-
@account.save!
-
end
-
-
def update_active_at
-
@current_account_user.active_at = Time.now.utc
-
@current_account_user.save!
-
head :ok
-
end
-
-
private
-
-
def ensure_account_name
-
# ensure that account_name and user_full_name is present
-
# this is becuase the account builder and the models validations are not triggered
-
# this change is to align the behaviour with the v2 accounts controller
-
# since these values are not required directly there
-
return if account_params[:account_name].present?
-
return if account_params[:user_full_name].present?
-
-
raise CustomExceptions::Account::InvalidParams.new({})
-
end
-
-
def cache_keys_for_account
-
{
-
label: fetch_value_for_key(params[:id], Label.name.underscore),
-
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
-
team: fetch_value_for_key(params[:id], Team.name.underscore)
-
}
-
end
-
-
def fetch_account
-
@account = current_user.accounts.find(params[:id])
-
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
-
end
-
-
def account_params
-
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
-
end
-
-
def custom_attributes_params
-
params.permit(:industry, :company_size, :timezone)
-
end
-
-
def settings_params
-
params.permit(:auto_resolve_after, :auto_resolve_message)
-
end
-
-
def check_signup_enabled
-
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
-
end
-
-
def validate_captcha
-
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
-
end
-
-
def pundit_user
-
{
-
user: current_user,
-
account: @account,
-
account_user: @current_account_user
-
}
-
end
-
end
-
class Api::V1::Integrations::WebhooksController < ApplicationController
-
def create
-
builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params)
-
response = builder.perform
-
render json: response
-
end
-
-
private
-
-
# TODO: This is a temporary solution to permit all params for slack unfurling job.
-
# We should only permit the params that we use. Handle all the params based on events and send it to the respective services.
-
def permitted_params
-
params.permit!
-
end
-
end
-
class Api::V1::NotificationSubscriptionsController < Api::BaseController
-
before_action :set_user
-
-
def create
-
notification_subscription = NotificationSubscriptionBuilder.new(user: @user, params: notification_subscription_params).perform
-
-
render json: notification_subscription
-
end
-
-
def destroy
-
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
-
notification_subscription.destroy! if notification_subscription.present?
-
head :ok
-
end
-
-
private
-
-
def set_user
-
@user = current_user
-
end
-
-
def notification_subscription_params
-
params.require(:notification_subscription).permit(:subscription_type, subscription_attributes: {})
-
end
-
end
-
class Api::V1::ProfilesController < Api::BaseController
-
before_action :set_user
-
-
def show; end
-
-
def update
-
if password_params[:password].present?
-
render_could_not_create_error('Invalid current password') and return unless @user.valid_password?(password_params[:current_password])
-
-
@user.update!(password_params.except(:current_password))
-
end
-
-
@user.assign_attributes(profile_params)
-
@user.custom_attributes.merge!(custom_attributes_params)
-
@user.save!
-
end
-
-
def avatar
-
@user.avatar.attachment.destroy! if @user.avatar.attached?
-
@user.reload
-
end
-
-
def auto_offline
-
@user.account_users.find_by!(account_id: auto_offline_params[:account_id]).update!(auto_offline: auto_offline_params[:auto_offline] || false)
-
end
-
-
def availability
-
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
-
end
-
-
def set_active_account
-
@user.account_users.find_by(account_id: profile_params[:account_id]).update(active_at: Time.now.utc)
-
head :ok
-
end
-
-
def resend_confirmation
-
@user.send_confirmation_instructions unless @user.confirmed?
-
head :ok
-
end
-
-
private
-
-
def set_user
-
@user = current_user
-
end
-
-
def availability_params
-
params.require(:profile).permit(:account_id, :availability)
-
end
-
-
def auto_offline_params
-
params.require(:profile).permit(:account_id, :auto_offline)
-
end
-
-
def profile_params
-
params.require(:profile).permit(
-
:email,
-
:name,
-
:display_name,
-
:avatar,
-
:message_signature,
-
:account_id,
-
ui_settings: {}
-
)
-
end
-
-
def custom_attributes_params
-
params.require(:profile).permit(:phone_number)
-
end
-
-
def password_params
-
params.require(:profile).permit(
-
:current_password,
-
:password,
-
:password_confirmation
-
)
-
end
-
end
-
class Api::V1::WebhooksController < ApplicationController
-
skip_before_action :authenticate_user!, raise: false
-
skip_before_action :set_current_user
-
-
def twitter_crc
-
render json: { response_token: "sha256=#{twitter_client.generate_crc(params[:crc_token])}" }
-
end
-
-
def twitter_events
-
twitter_consumer.consume
-
head :ok
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
head :ok
-
end
-
-
private
-
-
def twitter_client
-
Twitty::Facade.new do |config|
-
config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil)
-
end
-
end
-
-
def twitter_consumer
-
@twitter_consumer ||= ::Webhooks::Twitter.new(params)
-
end
-
end
-
class Api::V1::Widget::BaseController < ApplicationController
-
include SwitchLocale
-
include WebsiteTokenHelper
-
-
before_action :set_web_widget
-
before_action :set_contact
-
-
private
-
-
def conversations
-
if @contact_inbox.hmac_verified?
-
verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id)
-
@conversations = @contact.conversations.where(contact_inbox_id: verified_contact_inbox_ids)
-
else
-
@conversations = @contact_inbox.conversations.where(inbox_id: auth_token_params[:inbox_id])
-
end
-
end
-
-
def conversation
-
@conversation ||= conversations.last
-
end
-
-
def create_conversation
-
::Conversation.create!(conversation_params)
-
end
-
-
def inbox
-
@inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
-
end
-
-
def conversation_params
-
# FIXME: typo referrer in additional attributes, will probably require a migration.
-
{
-
account_id: inbox.account_id,
-
inbox_id: inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: {
-
browser_language: browser.accept_language&.first&.code,
-
browser: browser_params,
-
initiated_at: timestamp_params,
-
referer: permitted_params[:message][:referer_url]
-
},
-
custom_attributes: permitted_params[:custom_attributes].presence || {}
-
}
-
end
-
-
def contact_email
-
permitted_params.dig(:contact, :email)&.downcase
-
end
-
-
def contact_name
-
return if @contact.email.present? || @contact.phone_number.present? || @contact.identifier.present?
-
-
permitted_params.dig(:contact, :name) || (contact_email.split('@')[0] if contact_email.present?)
-
end
-
-
def contact_phone_number
-
permitted_params.dig(:contact, :phone_number)
-
end
-
-
def browser_params
-
{
-
browser_name: browser.name,
-
browser_version: browser.full_version,
-
device_name: browser.device.name,
-
platform_name: browser.platform.name,
-
platform_version: browser.platform.version
-
}
-
end
-
-
def timestamp_params
-
{ timestamp: permitted_params[:message][:timestamp] }
-
end
-
-
def message_params
-
{
-
account_id: conversation.account_id,
-
sender: @contact,
-
content: permitted_params[:message][:content],
-
inbox_id: conversation.inbox_id,
-
content_attributes: {
-
in_reply_to: permitted_params[:message][:reply_to]
-
},
-
echo_id: permitted_params[:message][:echo_id],
-
message_type: :incoming
-
}
-
end
-
end
-
class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
-
skip_before_action :set_contact
-
-
def index
-
account = @web_widget.inbox.account
-
@campaigns = if account.feature_enabled?('campaigns')
-
@web_widget
-
.inbox
-
.campaigns
-
.where(enabled: true, account_id: account.id)
-
.includes(:sender)
-
else
-
[]
-
end
-
end
-
end
-
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
-
before_action :set_global_config
-
-
def create
-
build_contact
-
set_token
-
end
-
-
private
-
-
def set_global_config
-
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
-
end
-
-
def set_contact
-
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
-
source_id: auth_token_params[:source_id]
-
)
-
@contact = @contact_inbox&.contact
-
end
-
-
def build_contact
-
return if @contact.present?
-
-
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
-
@contact = @contact_inbox.contact
-
end
-
-
def set_token
-
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
-
@token = ::Widget::TokenService.new(payload: payload).generate_token
-
end
-
-
def additional_attributes
-
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
-
{ created_at_ip: request.remote_ip }
-
else
-
{}
-
end
-
end
-
end
-
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
-
include WidgetHelper
-
-
before_action :validate_hmac, only: [:set_user]
-
-
def show; end
-
-
def update
-
identify_contact(@contact)
-
end
-
-
def set_user
-
contact = nil
-
-
if a_different_contact?
-
@contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget)
-
contact = @contact_inbox.contact
-
else
-
contact = @contact
-
end
-
-
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
-
-
identify_contact(contact)
-
end
-
-
# TODO : clean up this with proper routes delete contacts/custom_attributes
-
def destroy_custom_attributes
-
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
-
@contact.save!
-
render json: @contact
-
end
-
-
private
-
-
def identify_contact(contact)
-
contact_identify_action = ContactIdentifyAction.new(
-
contact: contact,
-
params: permitted_params.to_h.deep_symbolize_keys,
-
discard_invalid_attrs: true
-
)
-
@contact = contact_identify_action.perform
-
end
-
-
def a_different_contact?
-
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
-
end
-
-
def validate_hmac
-
return unless should_verify_hmac?
-
-
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
-
end
-
-
def should_verify_hmac?
-
return false if params[:identifier_hash].blank? && !@web_widget.hmac_mandatory
-
-
# Taking an extra caution that the hmac is triggered whenever identifier is present
-
return false if params[:custom_attributes].present? && params[:identifier].blank?
-
-
true
-
end
-
-
def valid_hmac?
-
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
-
'sha256',
-
@web_widget.hmac_token,
-
params[:identifier].to_s
-
)
-
end
-
-
def permitted_params
-
params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {},
-
additional_attributes: {})
-
end
-
end
-
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
-
include Events::Types
-
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
-
-
def index
-
@conversation = conversation
-
end
-
-
def create
-
ActiveRecord::Base.transaction do
-
process_update_contact
-
@conversation = create_conversation
-
conversation.messages.create!(message_params)
-
# TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer
-
conversation.reload
-
end
-
end
-
-
def process_update_contact
-
@contact = ContactIdentifyAction.new(
-
contact: @contact,
-
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
-
retain_original_contact_name: true,
-
discard_invalid_attrs: true
-
).perform
-
end
-
-
def update_last_seen
-
head :ok && return if conversation.nil?
-
-
conversation.contact_last_seen_at = DateTime.now.utc
-
conversation.save!
-
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, conversation.contact_last_seen_at)
-
head :ok
-
end
-
-
def transcript
-
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
-
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
-
conversation,
-
conversation.contact.email
-
)&.deliver_later
-
end
-
head :ok
-
end
-
-
def toggle_typing
-
case permitted_params[:typing_status]
-
when 'on'
-
trigger_typing_event(CONVERSATION_TYPING_ON)
-
when 'off'
-
trigger_typing_event(CONVERSATION_TYPING_OFF)
-
end
-
-
head :ok
-
end
-
-
def toggle_status
-
return head :forbidden unless @web_widget.end_conversation?
-
-
unless conversation.resolved?
-
conversation.status = :resolved
-
conversation.save!
-
end
-
head :ok
-
end
-
-
def set_custom_attributes
-
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
-
end
-
-
def destroy_custom_attributes
-
conversation.custom_attributes = conversation.custom_attributes.excluding(params[:custom_attribute])
-
conversation.save!
-
render json: conversation
-
end
-
-
private
-
-
def trigger_typing_event(event)
-
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
-
end
-
-
def render_not_found_if_empty
-
return head :not_found if conversation.nil?
-
end
-
-
def permitted_params
-
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
-
message: [:content, :referer_url, :timestamp, :echo_id],
-
custom_attributes: {})
-
end
-
end
-
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
-
include WebsiteTokenHelper
-
before_action :set_web_widget
-
before_action :set_contact
-
-
def create
-
return if @contact.nil? || @current_account.nil?
-
-
super
-
end
-
end
-
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
-
include Events::Types
-
-
def create
-
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,
-
event_info: permitted_params[:event_info].to_h.merge(event_info))
-
head :no_content
-
end
-
-
private
-
-
def event_info
-
{
-
widget_language: params[:locale],
-
browser_language: browser.accept_language.first&.code,
-
browser: browser_params
-
}
-
end
-
-
def permitted_params
-
params.permit(:name, :website_token, event_info: {})
-
end
-
end
-
class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
-
skip_before_action :set_contact
-
-
def index
-
@inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob })
-
end
-
end
-
class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseController
-
before_action :set_message
-
-
def add_participant_to_meeting
-
if @message.content_type != 'integrations'
-
return render json: {
-
error: I18n.t('errors.dyte.invalid_message_type')
-
}, status: :unprocessable_entity
-
end
-
-
response = dyte_processor_service.add_participant_to_meeting(
-
@message.content_attributes['data']['meeting_id'],
-
@conversation.contact
-
)
-
render_response(response)
-
end
-
-
private
-
-
def render_response(response)
-
render json: response, status: response[:error].blank? ? :ok : :unprocessable_entity
-
end
-
-
def dyte_processor_service
-
Integrations::Dyte::ProcessorService.new(account: @web_widget.inbox.account, conversation: @conversation)
-
end
-
-
def set_message
-
@message = @web_widget.inbox.messages.find(permitted_params[:message_id])
-
@conversation = @message.conversation
-
end
-
-
def permitted_params
-
params.permit(:website_token, :message_id)
-
end
-
end
-
class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController
-
def create
-
if conversation.present? && label_defined_in_account?
-
conversation.label_list.add(permitted_params[:label])
-
conversation.save!
-
end
-
-
head :no_content
-
end
-
-
def destroy
-
if conversation.present?
-
conversation.label_list.remove(permitted_params[:id])
-
conversation.save!
-
end
-
-
head :no_content
-
end
-
-
private
-
-
def label_defined_in_account?
-
label = @current_account.labels&.find_by(title: permitted_params[:label])
-
label.present?
-
end
-
-
def permitted_params
-
params.permit(:id, :label, :website_token)
-
end
-
end
-
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
-
before_action :set_conversation, only: [:create]
-
before_action :set_message, only: [:update]
-
-
def index
-
@messages = conversation.nil? ? [] : message_finder.perform
-
end
-
-
def create
-
@message = conversation.messages.new(message_params)
-
build_attachment
-
@message.save!
-
end
-
-
def update
-
if @message.content_type == 'input_email'
-
@message.update!(submitted_email: contact_email)
-
ContactIdentifyAction.new(
-
contact: @contact,
-
params: { email: contact_email, name: contact_name },
-
retain_original_contact_name: true
-
).perform
-
else
-
@message.update!(message_update_params[:message])
-
end
-
rescue StandardError => e
-
render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error
-
end
-
-
private
-
-
def build_attachment
-
return if params[:message][:attachments].blank?
-
-
params[:message][:attachments].each do |uploaded_attachment|
-
attachment = @message.attachments.new(
-
account_id: @message.account_id,
-
file: uploaded_attachment
-
)
-
-
attachment.file_type = helpers.file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
-
end
-
end
-
-
def set_conversation
-
@conversation = create_conversation if conversation.nil?
-
end
-
-
def message_finder_params
-
{
-
filter_internal_messages: true,
-
before: permitted_params[:before],
-
after: permitted_params[:after]
-
}
-
end
-
-
def message_finder
-
@message_finder ||= MessageFinder.new(conversation, message_finder_params)
-
end
-
-
def message_update_params
-
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }])
-
end
-
-
def permitted_params
-
# timestamp parameter is used in create conversation method
-
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
-
end
-
-
def set_message
-
@message = @web_widget.inbox.messages.find(permitted_params[:id])
-
end
-
end
-
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
-
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
-
before_action :set_group_scope, only: [:grouped_conversation_metrics]
-
-
before_action :check_authorization
-
-
def conversation_metrics
-
render json: {
-
open: @conversations.open.count,
-
unattended: @conversations.open.unattended.count,
-
unassigned: @conversations.open.unassigned.count,
-
pending: @conversations.pending.count
-
}
-
end
-
-
def grouped_conversation_metrics
-
count_by_group = @conversations.open.group(@group_scope).count
-
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
-
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
-
-
group_metrics = count_by_group.map do |group_id, count|
-
metric = {
-
open: count,
-
unattended: unattended_by_group[group_id] || 0,
-
unassigned: unassigned_by_group[group_id] || 0
-
}
-
metric[@group_scope] = group_id
-
metric
-
end
-
-
render json: group_metrics
-
end
-
-
private
-
-
def check_authorization
-
authorize :report, :view?
-
end
-
-
def set_group_scope
-
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
-
team_id
-
assignee_id
-
].include?(permitted_params[:group_by])
-
-
@group_scope = permitted_params[:group_by]
-
end
-
-
def team
-
return unless permitted_params[:team_id]
-
-
@team ||= Current.account.teams.find(permitted_params[:team_id])
-
end
-
-
def load_conversations
-
scope = Current.account.conversations
-
scope = scope.where(team_id: team.id) if team.present?
-
@conversations = scope
-
end
-
-
def permitted_params
-
params.permit(:team_id, :group_by)
-
end
-
end
-
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
-
include Api::V2::Accounts::ReportsHelper
-
include Api::V2::Accounts::HeatmapHelper
-
-
before_action :check_authorization
-
-
def index
-
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
-
data = builder.timeseries
-
render json: data
-
end
-
-
def summary
-
render json: build_summary(:summary)
-
end
-
-
def bot_summary
-
render json: build_summary(:bot_summary)
-
end
-
-
def agents
-
@report_data = generate_agents_report
-
generate_csv('agents_report', 'api/v2/accounts/reports/agents')
-
end
-
-
def inboxes
-
@report_data = generate_inboxes_report
-
generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes')
-
end
-
-
def labels
-
@report_data = generate_labels_report
-
generate_csv('labels_report', 'api/v2/accounts/reports/labels')
-
end
-
-
def teams
-
@report_data = generate_teams_report
-
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
-
end
-
-
def conversation_traffic
-
@report_data = generate_conversations_heatmap_report
-
timezone_offset = (params[:timezone_offset] || 0).to_f
-
@timezone = ActiveSupport::TimeZone[timezone_offset]
-
-
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
-
end
-
-
def conversations
-
return head :unprocessable_entity if params[:type].blank?
-
-
render json: conversation_metrics
-
end
-
-
def bot_metrics
-
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics
-
render json: bot_metrics
-
end
-
-
private
-
-
def generate_csv(filename, template)
-
response.headers['Content-Type'] = 'text/csv'
-
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
-
render layout: false, template: template, formats: [:csv]
-
end
-
-
def check_authorization
-
authorize :report, :view?
-
end
-
-
def common_params
-
{
-
type: params[:type].to_sym,
-
id: params[:id],
-
group_by: params[:group_by],
-
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
-
}
-
end
-
-
def current_summary_params
-
common_params.merge({
-
since: range[:current][:since],
-
until: range[:current][:until],
-
timezone_offset: params[:timezone_offset]
-
})
-
end
-
-
def previous_summary_params
-
common_params.merge({
-
since: range[:previous][:since],
-
until: range[:previous][:until],
-
timezone_offset: params[:timezone_offset]
-
})
-
end
-
-
def report_params
-
common_params.merge({
-
metric: params[:metric],
-
since: params[:since],
-
until: params[:until],
-
timezone_offset: params[:timezone_offset]
-
})
-
end
-
-
def conversation_params
-
{
-
type: params[:type].to_sym,
-
user_id: params[:user_id],
-
page: params[:page].presence || 1
-
}
-
end
-
-
def range
-
{
-
current: {
-
since: params[:since],
-
until: params[:until]
-
},
-
previous: {
-
since: (params[:since].to_i - (params[:until].to_i - params[:since].to_i)).to_s,
-
until: params[:since]
-
}
-
}
-
end
-
-
def build_summary(method)
-
builder = V2::Reports::Conversations::MetricBuilder
-
current_summary = builder.new(Current.account, current_summary_params).send(method)
-
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
-
current_summary.merge(previous: previous_summary)
-
end
-
-
def conversation_metrics
-
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
-
end
-
end
-
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
-
before_action :check_authorization
-
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
-
-
def agent
-
render_report_with(V2::Reports::AgentSummaryBuilder)
-
end
-
-
def team
-
render_report_with(V2::Reports::TeamSummaryBuilder)
-
end
-
-
def inbox
-
render_report_with(V2::Reports::InboxSummaryBuilder)
-
end
-
-
private
-
-
def check_authorization
-
authorize :report, :view?
-
end
-
-
def prepare_builder_params
-
@builder_params = {
-
since: permitted_params[:since],
-
until: permitted_params[:until],
-
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
-
}
-
end
-
-
def render_report_with(builder_class)
-
builder = builder_class.new(account: Current.account, params: @builder_params)
-
render json: builder.build
-
end
-
-
def permitted_params
-
params.permit(:since, :until, :business_hours)
-
end
-
end
-
class Api::V2::AccountsController < Api::BaseController
-
include AuthHelper
-
-
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
-
only: [:create], raise: false
-
before_action :check_signup_enabled, only: [:create]
-
before_action :validate_captcha, only: [:create]
-
before_action :fetch_account, except: [:create]
-
before_action :check_authorization, except: [:create]
-
-
rescue_from CustomExceptions::Account::InvalidEmail,
-
CustomExceptions::Account::UserExists,
-
CustomExceptions::Account::UserErrors,
-
with: :render_error_response
-
-
def create
-
@user, @account = AccountBuilder.new(
-
email: account_params[:email],
-
user_password: account_params[:password],
-
locale: account_params[:locale],
-
user: current_user
-
).perform
-
-
fetch_account_and_user_info
-
update_account_info if @account.present?
-
-
if @user
-
send_auth_headers(@user)
-
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
-
else
-
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
-
end
-
end
-
-
private
-
-
def account_attributes
-
{
-
custom_attributes: @account.custom_attributes.merge({ 'onboarding_step' => 'profile_update' })
-
}
-
end
-
-
def update_account_info
-
@account.update!(
-
account_attributes
-
)
-
end
-
-
def fetch_account_and_user_info; end
-
-
def fetch_account
-
@account = current_user.accounts.find(params[:id])
-
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
-
end
-
-
def account_params
-
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
-
end
-
-
def check_signup_enabled
-
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
-
end
-
-
def validate_captcha
-
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
-
end
-
end
-
-
Api::V2::AccountsController.prepend_mod_with('Api::V2::AccountsController')
-
class ApiController < ApplicationController
-
skip_before_action :set_current_user, only: [:index]
-
-
def index
-
render json: { version: Chatwoot.config[:version],
-
timestamp: Time.now.utc.to_fs(:db),
-
queue_services: redis_status,
-
data_services: postgres_status }
-
end
-
-
private
-
-
def redis_status
-
r = Redis.new(Redis::Config.app)
-
return 'ok' if r.ping
-
rescue Redis::CannotConnectError
-
'failing'
-
end
-
-
def postgres_status
-
ActiveRecord::Base.connection.active? ? 'ok' : 'failing'
-
rescue ActiveRecord::ConnectionNotEstablished
-
'failing'
-
end
-
end
-
class AppleAppController < ApplicationController
-
def site_association
-
site_association_json = render_to_string action: 'site_association', layout: false
-
send_data site_association_json, filename: 'apple-app-site-association', type: 'application/json'
-
end
-
end
-
class ApplicationController < ActionController::Base
-
include DeviseTokenAuth::Concerns::SetUserByToken
-
include RequestExceptionHandler
-
include Pundit::Authorization
-
include SwitchLocale
-
-
skip_before_action :verify_authenticity_token
-
-
before_action :set_current_user, unless: :devise_controller?
-
around_action :switch_locale
-
around_action :handle_with_exception, unless: :devise_controller?
-
-
private
-
-
def set_current_user
-
@user ||= current_user
-
Current.user = @user
-
end
-
-
def pundit_user
-
{
-
user: Current.user,
-
account: Current.account,
-
account_user: Current.account_user
-
}
-
end
-
end
-
ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern')
-
module AccessTokenAuthHelper
-
BOT_ACCESSIBLE_ENDPOINTS = {
-
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
-
'api/v1/accounts/conversations/messages' => ['create'],
-
'api/v1/accounts/conversations/assignments' => ['create']
-
}.freeze
-
-
def ensure_access_token
-
token = request.headers[:api_access_token] || request.headers[:HTTP_API_ACCESS_TOKEN]
-
@access_token = AccessToken.find_by(token: token) if token.present?
-
end
-
-
def authenticate_access_token!
-
ensure_access_token
-
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
-
-
@resource = @access_token.owner
-
Current.user = @resource if allowed_current_user_type?(@resource)
-
end
-
-
def allowed_current_user_type?(resource)
-
return true if resource.is_a?(User)
-
return true if resource.is_a?(AgentBot)
-
-
false
-
end
-
-
def validate_bot_access_token!
-
return if Current.user.is_a?(User)
-
return if agent_bot_accessible?
-
-
render_unauthorized('Access to this endpoint is not authorized for bots')
-
end
-
-
def agent_bot_accessible?
-
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
-
end
-
end
-
module AuthHelper
-
def send_auth_headers(user)
-
data = user.create_new_auth_token
-
response.headers[DeviseTokenAuth.headers_names[:'access-token']] = data['access-token']
-
response.headers[DeviseTokenAuth.headers_names[:'token-type']] = 'Bearer'
-
response.headers[DeviseTokenAuth.headers_names[:client]] = data['client']
-
response.headers[DeviseTokenAuth.headers_names[:expiry]] = data['expiry']
-
response.headers[DeviseTokenAuth.headers_names[:uid]] = data['uid']
-
end
-
end
-
module DomainHelper
-
def self.chatwoot_domain?(domain = request.host)
-
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
-
end
-
end
-
module EnsureCurrentAccountHelper
-
private
-
-
def current_account
-
@current_account ||= ensure_current_account
-
Current.account = @current_account
-
end
-
-
def ensure_current_account
-
account = Account.find(params[:account_id])
-
render_unauthorized('Account is suspended') and return unless account.active?
-
-
if current_user
-
account_accessible_for_user?(account)
-
elsif @resource.is_a?(AgentBot)
-
account_accessible_for_bot?(account)
-
end
-
account
-
end
-
-
def account_accessible_for_user?(account)
-
@current_account_user = account.account_users.find_by(user_id: current_user.id)
-
Current.account_user = @current_account_user
-
render_unauthorized('You are not authorized to access this account') unless @current_account_user
-
end
-
-
def account_accessible_for_bot?(account)
-
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
-
end
-
end
-
module GoogleConcern
-
extend ActiveSupport::Concern
-
-
def google_client
-
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
-
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
-
-
::OAuth2::Client.new(app_id, app_secret, {
-
site: 'https://oauth2.googleapis.com',
-
authorize_url: 'https://accounts.google.com/o/oauth2/auth',
-
token_url: 'https://accounts.google.com/o/oauth2/token'
-
})
-
end
-
-
private
-
-
def base_url
-
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
-
end
-
end
-
module HmacConcern
-
def hmac_verified?
-
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
-
end
-
end
-
module InstagramConcern
-
extend ActiveSupport::Concern
-
-
def instagram_client
-
::OAuth2::Client.new(
-
client_id,
-
client_secret,
-
{
-
site: 'https://api.instagram.com',
-
authorize_url: 'https://api.instagram.com/oauth/authorize',
-
token_url: 'https://api.instagram.com/oauth/access_token',
-
auth_scheme: :request_body,
-
token_method: :post
-
}
-
)
-
end
-
-
private
-
-
def client_id
-
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
-
end
-
-
def client_secret
-
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
-
end
-
-
def exchange_for_long_lived_token(short_lived_token)
-
endpoint = 'https://graph.instagram.com/access_token'
-
params = {
-
grant_type: 'ig_exchange_token',
-
client_secret: client_secret,
-
access_token: short_lived_token,
-
client_id: client_id
-
}
-
-
make_api_request(endpoint, params, 'Failed to exchange token')
-
end
-
-
def fetch_instagram_user_details(access_token)
-
endpoint = 'https://graph.instagram.com/v22.0/me'
-
params = {
-
fields: 'id,username,user_id,name,profile_picture_url,account_type',
-
access_token: access_token
-
}
-
-
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
-
end
-
-
def make_api_request(endpoint, params, error_prefix)
-
response = HTTParty.get(
-
endpoint,
-
query: params,
-
headers: { 'Accept' => 'application/json' }
-
)
-
-
unless response.success?
-
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
-
raise "#{error_prefix}: #{response.body}"
-
end
-
-
begin
-
JSON.parse(response.body)
-
rescue JSON::ParserError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
Rails.logger.error "Invalid JSON response: #{response.body}"
-
raise e
-
end
-
end
-
-
def base_url
-
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
-
end
-
end
-
module LabelConcern
-
def create
-
model.update_labels(permitted_params[:labels])
-
@labels = model.label_list
-
end
-
-
def index
-
@labels = model.label_list
-
end
-
end
-
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
-
# This concern handles the token verification step.
-
-
module MetaTokenVerifyConcern
-
def verify
-
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
-
if valid_token?(params['hub.verify_token'])
-
Rails.logger.info("#{service.capitalize} webhook verified")
-
render json: params['hub.challenge']
-
else
-
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
-
end
-
end
-
-
private
-
-
def valid_token?(_token)
-
raise 'Overwrite this method your controller'
-
end
-
end
-
module MicrosoftConcern
-
extend ActiveSupport::Concern
-
-
def microsoft_client
-
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
-
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
-
-
::OAuth2::Client.new(app_id, app_secret,
-
{
-
site: 'https://login.microsoftonline.com',
-
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
-
token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
-
})
-
end
-
-
private
-
-
def base_url
-
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
-
end
-
end
-
module RequestExceptionHandler
-
extend ActiveSupport::Concern
-
-
included do
-
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
-
end
-
-
private
-
-
def handle_with_exception
-
yield
-
rescue ActiveRecord::RecordNotFound => e
-
log_handled_error(e)
-
render_not_found_error('Resource could not be found')
-
rescue Pundit::NotAuthorizedError => e
-
log_handled_error(e)
-
render_unauthorized('You are not authorized to do this action')
-
rescue ActionController::ParameterMissing => e
-
log_handled_error(e)
-
render_could_not_create_error(e.message)
-
ensure
-
# to address the thread variable leak issues in Puma/Thin webserver
-
Current.reset
-
end
-
-
def render_unauthorized(message)
-
render json: { error: message }, status: :unauthorized
-
end
-
-
def render_not_found_error(message)
-
render json: { error: message }, status: :not_found
-
end
-
-
def render_could_not_create_error(message)
-
render json: { error: message }, status: :unprocessable_entity
-
end
-
-
def render_payment_required(message)
-
render json: { error: message }, status: :payment_required
-
end
-
-
def render_internal_server_error(message)
-
render json: { error: message }, status: :internal_server_error
-
end
-
-
def render_record_invalid(exception)
-
log_handled_error(exception)
-
render json: {
-
message: exception.record.errors.full_messages.join(', '),
-
attributes: exception.record.errors.attribute_names
-
}, status: :unprocessable_entity
-
end
-
-
def render_error_response(exception)
-
log_handled_error(exception)
-
render json: exception.to_hash, status: exception.http_status
-
end
-
-
def log_handled_error(exception)
-
logger.info("Handled error: #{exception.inspect}")
-
end
-
end
-
module SwitchLocale
-
extend ActiveSupport::Concern
-
-
private
-
-
def switch_locale(&)
-
# priority is for locale set in query string (mostly for widget/from js sdk)
-
locale ||= params[:locale]
-
-
locale ||= locale_from_custom_domain
-
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
-
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
-
set_locale(locale, &)
-
end
-
-
def switch_locale_using_account_locale(&)
-
locale = locale_from_account(@current_account)
-
set_locale(locale, &)
-
end
-
-
# If the request is coming from a custom domain, it should be for a helpcenter portal
-
# We will use the portal locale in such cases
-
def locale_from_custom_domain(&)
-
return if params[:locale]
-
-
domain = request.host
-
return if DomainHelper.chatwoot_domain?(domain)
-
-
@portal = Portal.find_by(custom_domain: domain)
-
return unless @portal
-
-
@portal.default_locale
-
end
-
-
def set_locale(locale, &)
-
safe_locale = validate_and_get_locale(locale)
-
# Ensure locale won't bleed into other requests
-
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
-
I18n.with_locale(safe_locale, &)
-
end
-
-
def validate_and_get_locale(locale)
-
return I18n.default_locale.to_s if locale.blank?
-
-
available_locales = I18n.available_locales.map(&:to_s)
-
locale_without_variant = locale.split('_')[0]
-
-
if available_locales.include?(locale)
-
locale
-
elsif available_locales.include?(locale_without_variant)
-
locale_without_variant
-
else
-
I18n.default_locale.to_s
-
end
-
end
-
-
def locale_from_account(account)
-
return unless account
-
-
account.locale
-
end
-
end
-
module TwitterConcern
-
extend ActiveSupport::Concern
-
-
private
-
-
def parsed_body
-
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
-
end
-
-
def host
-
ENV.fetch('FRONTEND_URL', '')
-
end
-
-
def twitter_client
-
Twitty::Facade.new do |config|
-
config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil)
-
config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil)
-
config.base_url = twitter_api_base_url
-
config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '')
-
end
-
end
-
-
def twitter_api_base_url
-
'https://api.twitter.com'
-
end
-
end
-
module WebsiteTokenHelper
-
def auth_token_params
-
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
-
end
-
-
def set_web_widget
-
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
-
@current_account = @web_widget.inbox.account
-
-
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
-
end
-
-
def set_contact
-
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
-
source_id: auth_token_params[:source_id]
-
)
-
@contact = @contact_inbox&.contact
-
raise ActiveRecord::RecordNotFound unless @contact
-
-
Current.contact = @contact
-
end
-
-
def permitted_params
-
params.permit(:website_token)
-
end
-
end
-
class DashboardController < ActionController::Base
-
include SwitchLocale
-
-
before_action :set_application_pack
-
before_action :set_global_config
-
before_action :set_dashboard_scripts
-
around_action :switch_locale
-
before_action :ensure_installation_onboarding, only: [:index]
-
before_action :render_hc_if_custom_domain, only: [:index]
-
before_action :ensure_html_format
-
layout 'vueapp'
-
-
def index; end
-
-
private
-
-
def ensure_html_format
-
head :not_acceptable unless request.format.html?
-
end
-
-
def set_global_config
-
@global_config = GlobalConfig.get(
-
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
-
'INSTALLATION_NAME',
-
'WIDGET_BRAND_URL', 'TERMS_URL',
-
'BRAND_URL', 'BRAND_NAME',
-
'PRIVACY_URL',
-
'DISPLAY_MANIFEST',
-
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
-
'CHATWOOT_INBOX_TOKEN',
-
'API_CHANNEL_NAME',
-
'API_CHANNEL_THUMBNAIL',
-
'ANALYTICS_TOKEN',
-
'DIRECT_UPLOADS_ENABLED',
-
'HCAPTCHA_SITE_KEY',
-
'LOGOUT_REDIRECT_LINK',
-
'DISABLE_USER_PROFILE_UPDATE',
-
'DEPLOYMENT_ENV',
-
'INSTALLATION_PRICING_PLAN'
-
).merge(app_config)
-
end
-
-
def set_dashboard_scripts
-
@dashboard_scripts = sensitive_path? ? nil : GlobalConfig.get_value('DASHBOARD_SCRIPTS')
-
end
-
-
def ensure_installation_onboarding
-
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
-
end
-
-
def render_hc_if_custom_domain
-
domain = request.host
-
return if domain == URI.parse(ENV.fetch('FRONTEND_URL', '')).host
-
-
@portal = Portal.find_by(custom_domain: domain)
-
return unless @portal
-
-
@locale = @portal.default_locale
-
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
-
end
-
-
def app_config
-
{
-
APP_VERSION: Chatwoot.config[:version],
-
VAPID_PUBLIC_KEY: VapidService.public_key,
-
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
-
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
-
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
-
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
-
IS_ENTERPRISE: ChatwootApp.enterprise?,
-
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
-
GIT_SHA: GIT_HASH
-
}
-
end
-
-
def set_application_pack
-
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
-
'v3app'
-
else
-
'dashboard'
-
end
-
end
-
-
def sensitive_path?
-
# dont load dashboard scripts on sensitive paths like password reset
-
sensitive_paths = [edit_user_password_path].freeze
-
-
# remove app prefix
-
current_path = request.path.gsub(%r{^/app}, '')
-
-
sensitive_paths.include?(current_path)
-
end
-
end
-
class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
-
include AuthHelper
-
skip_before_action :require_no_authentication, raise: false
-
skip_before_action :authenticate_user!, raise: false
-
-
def create
-
@confirmable = User.find_by(confirmation_token: params[:confirmation_token])
-
render_confirmation_success and return if @confirmable&.confirm
-
-
render_confirmation_error
-
end
-
-
private
-
-
def render_confirmation_success
-
send_auth_headers(@confirmable)
-
render partial: 'devise/auth', formats: [:json], locals: { resource: @confirmable }
-
end
-
-
def render_confirmation_error
-
if @confirmable.blank?
-
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
-
elsif @confirmable.confirmed_at
-
render json: { message: 'Already confirmed', redirect_url: '/' }, status: :unprocessable_entity
-
else
-
render json: { message: 'Failure', redirect_url: '/' }, status: :unprocessable_entity
-
end
-
end
-
-
def create_reset_token_link(user)
-
token = user.send(:set_reset_password_token)
-
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{token}"
-
end
-
end
-
class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
-
include EmailHelper
-
-
def omniauth_success
-
get_resource_from_auth_hash
-
-
@resource.present? ? sign_in_user : sign_up_user
-
end
-
-
private
-
-
def sign_in_user
-
@resource.skip_confirmation! if confirmable_enabled?
-
-
# once the resource is found and verified
-
# we can just send them to the login page again with the SSO params
-
# that will log them in
-
encoded_email = ERB::Util.url_encode(@resource.email)
-
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
-
end
-
-
def sign_up_user
-
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
-
return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
-
-
create_account_for_user
-
token = @resource.send(:set_reset_password_token)
-
frontend_url = ENV.fetch('FRONTEND_URL', nil)
-
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
-
end
-
-
def login_page_url(error: nil, email: nil, sso_auth_token: nil)
-
frontend_url = ENV.fetch('FRONTEND_URL', nil)
-
params = { email: email, sso_auth_token: sso_auth_token }.compact
-
params[:error] = error if error.present?
-
-
"#{frontend_url}/app/login?#{params.to_query}"
-
end
-
-
def account_signup_allowed?
-
# set it to true by default, this is the behaviour across the app
-
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
-
end
-
-
def resource_class(_mapping = nil)
-
User
-
end
-
-
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
-
# find the user with their email instead of UID and token
-
@resource = resource_class.where(
-
email: auth_hash['info']['email']
-
).first
-
end
-
-
def validate_business_account?
-
# return true if the user is a business account, false if it is a gmail account
-
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
-
end
-
-
def create_account_for_user
-
@resource, @account = AccountBuilder.new(
-
account_name: extract_domain_without_tld(auth_hash['info']['email']),
-
user_full_name: auth_hash['info']['name'],
-
email: auth_hash['info']['email'],
-
locale: I18n.locale,
-
confirmed: auth_hash['info']['email_verified']
-
).perform
-
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
-
end
-
-
def default_devise_mapping
-
'user'
-
end
-
end
-
class DeviseOverrides::PasswordsController < Devise::PasswordsController
-
include AuthHelper
-
-
skip_before_action :require_no_authentication, raise: false
-
skip_before_action :authenticate_user!, raise: false
-
-
def create
-
@user = User.from_email(params[:email])
-
if @user
-
@user.send_reset_password_instructions
-
build_response(I18n.t('messages.reset_password_success'), 200)
-
else
-
build_response(I18n.t('messages.reset_password_failure'), 404)
-
end
-
end
-
-
def update
-
# params: reset_password_token, password, password_confirmation
-
original_token = params[:reset_password_token]
-
reset_password_token = Devise.token_generator.digest(self, :reset_password_token, original_token)
-
@recoverable = User.find_by(reset_password_token: reset_password_token)
-
if @recoverable && reset_password_and_confirmation(@recoverable)
-
send_auth_headers(@recoverable)
-
render partial: 'devise/auth', formats: [:json], locals: { resource: @recoverable }
-
else
-
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def reset_password_and_confirmation(recoverable)
-
recoverable.confirm unless recoverable.confirmed? # confirm if user resets password without confirming anytime before
-
recoverable.reset_password(params[:password], params[:password_confirmation])
-
recoverable.reset_password_token = nil
-
recoverable.confirmation_token = nil
-
recoverable.reset_password_sent_at = nil
-
recoverable.save!
-
end
-
-
def build_response(message, status)
-
render json: {
-
message: message
-
}, status: status
-
end
-
end
-
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
-
# Prevent session parameter from being passed
-
# Unpermitted parameter: session
-
wrap_parameters format: []
-
before_action :process_sso_auth_token, only: [:create]
-
-
def new
-
redirect_to login_page_url(error: 'access-denied')
-
end
-
-
def create
-
# Authenticate user via the temporary sso auth token
-
if params[:sso_auth_token].present? && @resource.present?
-
authenticate_resource_with_sso_token
-
yield @resource if block_given?
-
render_create_success
-
else
-
super
-
end
-
end
-
-
def render_create_success
-
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
-
end
-
-
private
-
-
def login_page_url(error: nil)
-
frontend_url = ENV.fetch('FRONTEND_URL', nil)
-
-
"#{frontend_url}/app/login?error=#{error}"
-
end
-
-
def authenticate_resource_with_sso_token
-
@token = @resource.create_token
-
@resource.save!
-
-
sign_in(:user, @resource, store: false, bypass: false)
-
# invalidate the token after the user is signed in
-
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
-
end
-
-
def process_sso_auth_token
-
return if params[:email].blank?
-
-
user = User.from_email(params[:email])
-
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
-
end
-
end
-
-
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
-
class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
-
def validate_token
-
# @resource will have been set by set_user_by_token concern
-
if @resource
-
render 'devise/token', formats: [:json]
-
else
-
render_validate_token_error
-
end
-
end
-
end
-
class Google::CallbacksController < OauthCallbackController
-
include GoogleConcern
-
-
def find_channel_by_email
-
# find by imap_login first, and then by email
-
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
-
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
-
return imap_channel if imap_channel
-
-
Channel::Email.find_by(email: users_data['email'], account: account)
-
end
-
-
private
-
-
def provider_name
-
'google'
-
end
-
-
def imap_address
-
'imap.gmail.com'
-
end
-
-
def oauth_client
-
# from GoogleConcern
-
google_client
-
end
-
end
-
class Instagram::CallbacksController < ApplicationController
-
include InstagramConcern
-
include Instagram::IntegrationHelper
-
-
def show
-
# Check if Instagram redirected with an error (user canceled authorization)
-
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
-
if params[:error].present?
-
handle_authorization_error
-
return
-
end
-
-
process_successful_authorization
-
rescue StandardError => e
-
handle_error(e)
-
end
-
-
private
-
-
# Process the authorization code and create inbox
-
def process_successful_authorization
-
@response = instagram_client.auth_code.get_token(
-
oauth_code,
-
redirect_uri: "#{base_url}/#{provider_name}/callback",
-
grant_type: 'authorization_code'
-
)
-
-
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
-
inbox, already_exists = find_or_create_inbox
-
-
if already_exists
-
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
-
else
-
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
-
end
-
end
-
-
# Handle all errors that might occur during authorization
-
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
-
def handle_error(error)
-
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
-
ChatwootExceptionTracker.new(error).capture_exception
-
-
error_info = extract_error_info(error)
-
redirect_to_error_page(error_info)
-
end
-
-
# Extract error details from the exception
-
def extract_error_info(error)
-
if error.is_a?(OAuth2::Error)
-
begin
-
# Instagram returns JSON error response which we parse to extract error details
-
JSON.parse(error.message)
-
rescue JSON::ParseError
-
# Fall back to a generic OAuth error if JSON parsing fails
-
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
-
end
-
else
-
# For other unexpected errors
-
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
-
end
-
end
-
-
# Handles the case when a user denies permissions or cancels the authorization flow
-
# Error parameters are documented at:
-
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
-
def handle_authorization_error
-
error_info = {
-
'error_type' => params[:error] || 'authorization_error',
-
'code' => 400,
-
'error_message' => params[:error_description] || 'Authorization was denied'
-
}
-
-
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
-
redirect_to_error_page(error_info)
-
end
-
-
# Centralized method to redirect to error page with appropriate parameters
-
# This ensures consistent error handling across different error scenarios
-
# Frontend will handle the error page based on the error_type
-
def redirect_to_error_page(error_info)
-
redirect_to app_new_instagram_inbox_url(
-
account_id: account_id,
-
error_type: error_info['error_type'],
-
code: error_info['code'],
-
error_message: error_info['error_message']
-
)
-
end
-
-
def find_or_create_inbox
-
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
-
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
-
channel_exists = channel_instagram.present?
-
-
if channel_instagram
-
update_channel(channel_instagram, user_details)
-
else
-
channel_instagram = create_channel_with_inbox(user_details)
-
end
-
-
# reauthorize channel, this code path only triggers when instagram auth is successful
-
# reauthorized will also update cache keys for the associated inbox
-
channel_instagram.reauthorized!
-
-
[channel_instagram.inbox, channel_exists]
-
end
-
-
def find_channel_by_instagram_id(instagram_id)
-
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
-
end
-
-
def update_channel(channel_instagram, user_details)
-
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
-
-
channel_instagram.update!(
-
access_token: @long_lived_token_response['access_token'],
-
expires_at: expires_at
-
)
-
-
# Update inbox name if username changed
-
channel_instagram.inbox.update!(name: user_details['username'])
-
channel_instagram
-
end
-
-
def create_channel_with_inbox(user_details)
-
ActiveRecord::Base.transaction do
-
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
-
-
channel_instagram = Channel::Instagram.create!(
-
access_token: @long_lived_token_response['access_token'],
-
instagram_id: user_details['user_id'].to_s,
-
account: account,
-
expires_at: expires_at
-
)
-
-
account.inboxes.create!(
-
account: account,
-
channel: channel_instagram,
-
name: user_details['username']
-
)
-
-
channel_instagram
-
end
-
end
-
-
def account_id
-
return unless params[:state]
-
-
verify_instagram_token(params[:state])
-
end
-
-
def oauth_code
-
params[:code]
-
end
-
-
def account
-
@account ||= Account.find(account_id)
-
end
-
-
def provider_name
-
'instagram'
-
end
-
end
-
class Installation::OnboardingController < ApplicationController
-
before_action :ensure_installation_onboarding
-
-
def index; end
-
-
def create
-
begin
-
AccountBuilder.new(
-
account_name: onboarding_params.dig(:user, :company),
-
user_full_name: onboarding_params.dig(:user, :name),
-
email: onboarding_params.dig(:user, :email),
-
user_password: params.dig(:user, :password),
-
super_admin: true,
-
confirmed: true
-
).perform
-
rescue StandardError => e
-
redirect_to '/', flash: { error: e.message } and return
-
end
-
finish_onboarding
-
redirect_to '/'
-
end
-
-
private
-
-
def onboarding_params
-
params.permit(:subscribe_to_updates, user: [:name, :company, :email])
-
end
-
-
def finish_onboarding
-
::Redis::Alfred.delete(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
-
return if onboarding_params[:subscribe_to_updates].blank?
-
-
ChatwootHub.register_instance(
-
onboarding_params.dig(:user, :company),
-
onboarding_params.dig(:user, :name),
-
onboarding_params.dig(:user, :email)
-
)
-
end
-
-
def ensure_installation_onboarding
-
redirect_to '/' unless ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
-
end
-
end
-
class Linear::CallbacksController < ApplicationController
-
include Linear::IntegrationHelper
-
-
def show
-
@response = oauth_client.auth_code.get_token(
-
params[:code],
-
redirect_uri: "#{base_url}/linear/callback"
-
)
-
-
handle_response
-
rescue StandardError => e
-
Rails.logger.error("Linear callback error: #{e.message}")
-
redirect_to linear_redirect_uri
-
end
-
-
private
-
-
def oauth_client
-
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
-
app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
-
-
OAuth2::Client.new(
-
app_id,
-
app_secret,
-
{
-
site: 'https://api.linear.app',
-
token_url: '/oauth/token',
-
authorize_url: '/oauth/authorize'
-
}
-
)
-
end
-
-
def handle_response
-
hook = account.hooks.new(
-
access_token: parsed_body['access_token'],
-
status: 'enabled',
-
app_id: 'linear',
-
settings: {
-
token_type: parsed_body['token_type'],
-
expires_in: parsed_body['expires_in'],
-
scope: parsed_body['scope']
-
}
-
)
-
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
-
hook.save!
-
redirect_to linear_redirect_uri
-
rescue StandardError => e
-
Rails.logger.error("Linear callback error: #{e.message}")
-
redirect_to linear_redirect_uri
-
end
-
-
def account
-
@account ||= Account.find(account_id)
-
end
-
-
def account_id
-
return unless params[:state]
-
-
verify_linear_token(params[:state])
-
end
-
-
def linear_redirect_uri
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
-
end
-
-
def parsed_body
-
@parsed_body ||= @response.response.parsed
-
end
-
-
def base_url
-
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
-
end
-
end
-
class Microsoft::CallbacksController < OauthCallbackController
-
include MicrosoftConcern
-
-
private
-
-
def oauth_client
-
microsoft_client
-
end
-
-
def provider_name
-
'microsoft'
-
end
-
-
def imap_address
-
'outlook.office365.com'
-
end
-
end
-
class MicrosoftController < ApplicationController
-
after_action :set_version_header
-
-
def identity_association
-
microsoft_indentity
-
end
-
-
private
-
-
def set_version_header
-
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
-
end
-
-
def microsoft_indentity
-
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
-
end
-
end
-
class OauthCallbackController < ApplicationController
-
def show
-
@response = oauth_client.auth_code.get_token(
-
oauth_code,
-
redirect_uri: "#{base_url}/#{provider_name}/callback"
-
)
-
-
handle_response
-
::Redis::Alfred.delete(cache_key)
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
redirect_to '/'
-
end
-
-
private
-
-
def handle_response
-
inbox, already_exists = find_or_create_inbox
-
-
if already_exists
-
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
-
else
-
redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
-
end
-
end
-
-
def find_or_create_inbox
-
channel_email = find_channel_by_email
-
# we need this value to know where to redirect on sucessful processing of the callback
-
channel_exists = channel_email.present?
-
-
channel_email ||= create_channel_with_inbox
-
update_channel(channel_email)
-
-
# reauthorize channel, this code path only triggers when microsoft auth is successful
-
# reauthorized will also update cache keys for the associated inbox
-
channel_email.reauthorized!
-
-
[channel_email.inbox, channel_exists]
-
end
-
-
def find_channel_by_email
-
Channel::Email.find_by(email: users_data['email'], account: account)
-
end
-
-
def update_channel(channel_email)
-
channel_email.update!({
-
imap_login: users_data['email'], imap_address: imap_address,
-
imap_port: '993', imap_enabled: true,
-
provider: provider_name,
-
provider_config: {
-
access_token: parsed_body['access_token'],
-
refresh_token: parsed_body['refresh_token'],
-
expires_on: (Time.current.utc + 1.hour).to_s
-
}
-
})
-
end
-
-
def provider_name
-
raise NotImplementedError
-
end
-
-
def oauth_client
-
raise NotImplementedError
-
end
-
-
def cache_key
-
"#{provider_name}::#{users_data['email'].downcase}"
-
end
-
-
def create_channel_with_inbox
-
ActiveRecord::Base.transaction do
-
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
-
account.inboxes.create!(
-
account: account,
-
channel: channel_email,
-
name: users_data['name'] || fallback_name
-
)
-
channel_email
-
end
-
end
-
-
def users_data
-
decoded_token = JWT.decode parsed_body[:id_token], nil, false
-
decoded_token[0]
-
end
-
-
def account_id
-
::Redis::Alfred.get(cache_key)
-
end
-
-
def account
-
@account ||= Account.find(account_id)
-
end
-
-
# Fallback name, for when name field is missing from users_data
-
def fallback_name
-
users_data['email'].split('@').first.parameterize.titleize
-
end
-
-
def oauth_code
-
params[:code]
-
end
-
-
def base_url
-
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
-
end
-
-
def parsed_body
-
@parsed_body ||= @response.response.parsed
-
end
-
end
-
class Platform::Api::V1::AccountUsersController < PlatformController
-
before_action :set_resource
-
before_action :validate_platform_app_permissible
-
-
def index
-
render json: @resource.account_users
-
end
-
-
def create
-
@account_user = @resource.account_users.find_or_initialize_by(user_id: account_user_params[:user_id])
-
@account_user.update!(account_user_params)
-
render json: @account_user
-
end
-
-
def destroy
-
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
-
head :ok
-
end
-
-
private
-
-
def set_resource
-
@resource = Account.find(params[:account_id])
-
end
-
-
def account_user_params
-
params.permit(:user_id, :role)
-
end
-
end
-
class Platform::Api::V1::AccountsController < PlatformController
-
def show; end
-
-
def create
-
@resource = Account.create!(account_params)
-
update_resource_features
-
@resource.save!
-
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
-
end
-
-
def update
-
@resource.assign_attributes(account_params)
-
update_resource_features
-
@resource.save!
-
end
-
-
def destroy
-
DeleteObjectJob.perform_later(@resource)
-
head :ok
-
end
-
-
private
-
-
def set_resource
-
@resource = Account.find(params[:id])
-
end
-
-
def account_params
-
permitted_params.except(:features)
-
end
-
-
def update_resource_features
-
return if permitted_params[:features].blank?
-
-
permitted_params[:features].each do |key, value|
-
value.present? ? @resource.enable_features(key) : @resource.disable_features(key)
-
end
-
end
-
-
def permitted_params
-
params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {})
-
end
-
end
-
class Platform::Api::V1::AgentBotsController < PlatformController
-
before_action :set_resource, except: [:index, :create]
-
before_action :validate_platform_app_permissible, except: [:index, :create]
-
-
def index
-
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
-
end
-
-
def show; end
-
-
def create
-
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
-
@resource.save!
-
process_avatar_from_url
-
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
-
end
-
-
def update
-
@resource.update!(agent_bot_params.except(:avatar_url))
-
process_avatar_from_url
-
end
-
-
def destroy
-
@resource.destroy!
-
head :ok
-
end
-
-
def avatar
-
@resource.avatar.purge if @resource.avatar.attached?
-
@resource
-
end
-
-
private
-
-
def set_resource
-
@resource = AgentBot.find(params[:id])
-
end
-
-
def agent_bot_params
-
params.permit(:name, :description, :account_id, :outgoing_url, :avatar, :avatar_url)
-
end
-
-
def process_avatar_from_url
-
::Avatar::AvatarFromUrlJob.perform_later(@resource, params[:avatar_url]) if params[:avatar_url].present?
-
end
-
end
-
class Platform::Api::V1::UsersController < PlatformController
-
# ref: https://stackoverflow.com/a/45190318/939299
-
# set resource is called for other actions already in platform controller
-
# we want to add login to that chain as well
-
before_action(only: [:login]) { set_resource }
-
before_action(only: [:login]) { validate_platform_app_permissible }
-
-
def show; end
-
-
def create
-
@resource = (User.from_email(user_params[:email]) || User.new(user_params))
-
@resource.skip_confirmation!
-
@resource.save!
-
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
-
end
-
-
def login
-
render json: { url: @resource.generate_sso_link }
-
end
-
-
def update
-
@resource.assign_attributes(user_update_params)
-
-
# We are using devise's reconfirmable flow for changing emails
-
# But in case of platform APIs we don't want user to go through this extra step
-
@resource.skip_reconfirmation! if user_update_params[:email].present?
-
@resource.save!
-
end
-
-
def destroy
-
DeleteObjectJob.perform_later(@resource)
-
head :ok
-
end
-
-
private
-
-
def user_custom_attributes
-
return @resource.custom_attributes.merge(user_params[:custom_attributes]) if user_params[:custom_attributes]
-
-
@resource.custom_attributes
-
end
-
-
def user_update_params
-
# we want the merged custom attributes not the original one
-
user_params.except(:custom_attributes).merge({ custom_attributes: user_custom_attributes })
-
end
-
-
def set_resource
-
@resource = User.find(params[:id])
-
end
-
-
def user_params
-
params.permit(:name, :display_name, :email, :password, custom_attributes: {})
-
end
-
end
-
class PlatformController < ActionController::API
-
include RequestExceptionHandler
-
-
before_action :ensure_access_token
-
before_action :set_platform_app
-
before_action :set_resource, only: [:update, :show, :destroy]
-
before_action :validate_platform_app_permissible, only: [:update, :show, :destroy]
-
-
def show; end
-
-
def update; end
-
-
def destroy; end
-
-
private
-
-
def ensure_access_token
-
token = request.headers[:api_access_token] || request.headers[:HTTP_API_ACCESS_TOKEN]
-
@access_token = AccessToken.find_by(token: token) if token.present?
-
end
-
-
def set_platform_app
-
@platform_app = @access_token.owner if @access_token && @access_token.owner.is_a?(PlatformApp)
-
render json: { error: 'Invalid access_token' }, status: :unauthorized if @platform_app.blank?
-
end
-
-
def set_resource
-
# set @resource in your controller
-
raise 'Overwrite this method your controller'
-
end
-
-
def validate_platform_app_permissible
-
return if @platform_app.platform_app_permissibles.find_by(permissible: @resource)
-
-
render json: { error: 'Non permissible resource' }, status: :unauthorized
-
end
-
end
-
class Public::Api::V1::CsatSurveyController < PublicController
-
before_action :set_conversation
-
before_action :set_message
-
-
def show; end
-
-
def update
-
render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked
-
-
@message.update!(message_update_params[:message])
-
end
-
-
private
-
-
def set_conversation
-
return if params[:id].blank?
-
-
@conversation = Conversation.find_by!(uuid: params[:id])
-
end
-
-
def set_message
-
@message = @conversation.messages.find_by!(content_type: 'input_csat')
-
end
-
-
def message_update_params
-
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }])
-
end
-
-
def check_csat_locked
-
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14
-
end
-
end
-
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
-
before_action :contact_inbox, except: [:create]
-
before_action :process_hmac
-
-
def show; end
-
-
def create
-
source_id = params[:source_id] || SecureRandom.uuid
-
@contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: source_id,
-
inbox: @inbox_channel.inbox,
-
contact_attributes: permitted_params.except(:identifier_hash)
-
).perform
-
end
-
-
def update
-
contact_identify_action = ContactIdentifyAction.new(
-
contact: @contact_inbox.contact,
-
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
-
)
-
render json: contact_identify_action.perform
-
end
-
-
private
-
-
def contact_inbox
-
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:id])
-
end
-
-
def process_hmac
-
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
-
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
-
-
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
-
end
-
-
def valid_hmac?
-
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
-
'sha256',
-
@inbox_channel.hmac_token,
-
params[:identifier].to_s
-
)
-
end
-
-
def permitted_params
-
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
-
end
-
end
-
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
-
include Events::Types
-
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
-
-
def index
-
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
-
end
-
-
def show; end
-
-
def create
-
@conversation = create_conversation
-
end
-
-
def toggle_status
-
# Check if the conversation is already resolved to prevent redundant operations
-
return if @conversation.resolved?
-
-
# Assign the conversation's contact as the resolver
-
# This step attributes the resolution action to the contact involved in the conversation
-
# If this assignment is not made, the system implicitly becomes the resolver by default
-
Current.contact = @conversation.contact
-
-
# Update the conversation's status to 'resolved' to reflect its closure
-
@conversation.status = :resolved
-
@conversation.save!
-
end
-
-
def toggle_typing
-
case params[:typing_status]
-
when 'on'
-
trigger_typing_event(CONVERSATION_TYPING_ON)
-
when 'off'
-
trigger_typing_event(CONVERSATION_TYPING_OFF)
-
end
-
head :ok
-
end
-
-
def update_last_seen
-
@conversation.contact_last_seen_at = DateTime.now.utc
-
@conversation.save!
-
::Conversations::UpdateMessageStatusJob.perform_later(@conversation.id, @conversation.contact_last_seen_at)
-
head :ok
-
end
-
-
private
-
-
def set_conversation
-
@conversation = if @contact_inbox.hmac_verified?
-
@contact_inbox.contact.conversations.find_by!(display_id: params[:id])
-
else
-
@contact_inbox.conversations.find_by!(display_id: params[:id])
-
end
-
end
-
-
def create_conversation
-
ConversationBuilder.new(params: conversation_params, contact_inbox: @contact_inbox).perform
-
end
-
-
def trigger_typing_event(event)
-
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: @conversation.contact)
-
end
-
-
def conversation_params
-
params.permit(custom_attributes: {})
-
end
-
end
-
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
-
before_action :set_message, only: [:update]
-
-
def index
-
@messages = @conversation.nil? ? [] : message_finder.perform
-
end
-
-
def create
-
@message = @conversation.messages.new(message_params)
-
build_attachment
-
@message.save!
-
end
-
-
def update
-
render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked
-
-
@message.update!(message_update_params)
-
rescue StandardError => e
-
render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error
-
end
-
-
private
-
-
def build_attachment
-
return if params[:attachments].blank?
-
-
params[:attachments].each do |uploaded_attachment|
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: helpers.file_type(uploaded_attachment&.content_type),
-
file: uploaded_attachment
-
)
-
end
-
end
-
-
def message_finder_params
-
{
-
filter_internal_messages: true,
-
before: params[:before]
-
}
-
end
-
-
def message_finder
-
@message_finder ||= MessageFinder.new(@conversation, message_finder_params)
-
end
-
-
def message_update_params
-
params.permit(submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }])
-
end
-
-
def permitted_params
-
params.permit(:content, :echo_id)
-
end
-
-
def set_message
-
@message = @conversation.messages.find(params[:id])
-
end
-
-
def message_params
-
{
-
account_id: @conversation.account_id,
-
sender: @contact_inbox.contact,
-
content: permitted_params[:content],
-
inbox_id: @conversation.inbox_id,
-
echo_id: permitted_params[:echo_id],
-
message_type: :incoming
-
}
-
end
-
-
def check_csat_locked
-
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 and @message.content_type == 'input_csat'
-
end
-
end
-
class Public::Api::V1::InboxesController < PublicController
-
before_action :set_inbox_channel
-
before_action :set_contact_inbox
-
before_action :set_conversation
-
-
def show
-
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:id])
-
end
-
-
private
-
-
def set_inbox_channel
-
return if params[:inbox_id].blank?
-
-
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
-
end
-
-
def set_contact_inbox
-
return if params[:contact_id].blank?
-
-
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:contact_id])
-
end
-
-
def set_conversation
-
return if params[:conversation_id].blank?
-
-
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
-
end
-
end
-
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
-
before_action :ensure_custom_domain_request, only: [:show, :index]
-
before_action :portal
-
before_action :set_category, except: [:index, :show]
-
before_action :set_article, only: [:show]
-
layout 'portal'
-
-
def index
-
@articles = @portal.articles.published.includes(:category, :author)
-
@articles_count = @articles.count
-
search_articles
-
order_by_sort_param
-
limit_results
-
end
-
-
def show; end
-
-
private
-
-
def limit_results
-
return if list_params[:per_page].blank?
-
-
per_page = [list_params[:per_page].to_i, 100].min
-
per_page = 25 if per_page < 1
-
@articles = @articles.page(list_params[:page]).per(per_page)
-
end
-
-
def search_articles
-
@articles = @articles.search(list_params) if list_params.present?
-
end
-
-
def order_by_sort_param
-
@articles = if list_params[:sort].present? && list_params[:sort] == 'views'
-
@articles.order_by_views
-
else
-
@articles.order_by_position
-
end
-
end
-
-
def set_article
-
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
-
@article.increment_view_count if @article.published?
-
@parsed_content = render_article_content(@article.content)
-
end
-
-
def set_category
-
return if permitted_params[:category_slug].blank?
-
-
@category = @portal.categories.find_by!(
-
slug: permitted_params[:category_slug],
-
locale: permitted_params[:locale]
-
)
-
end
-
-
def list_params
-
params.permit(:query, :locale, :sort, :status, :page, :per_page)
-
end
-
-
def permitted_params
-
params.permit(:slug, :category_slug, :locale, :id, :article_slug)
-
end
-
-
def render_article_content(content)
-
ChatwootMarkdownRenderer.new(content).render_article
-
end
-
end
-
-
Public::Api::V1::Portals::ArticlesController.prepend_mod_with('Public::Api::V1::Portals::ArticlesController')
-
class Public::Api::V1::Portals::BaseController < PublicController
-
include SwitchLocale
-
-
before_action :show_plain_layout
-
before_action :set_color_scheme
-
before_action :set_global_config
-
around_action :set_locale
-
after_action :allow_iframe_requests
-
-
private
-
-
def show_plain_layout
-
@is_plain_layout_enabled = params[:show_plain_layout] == 'true'
-
end
-
-
def set_color_scheme
-
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
-
end
-
-
def portal
-
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
-
end
-
-
def set_locale(&)
-
switch_locale_with_portal(&) if params[:locale].present?
-
switch_locale_with_article(&) if params[:article_slug].present?
-
-
yield
-
end
-
-
def switch_locale_with_portal(&)
-
@locale = validate_and_get_locale(params[:locale])
-
-
I18n.with_locale(@locale, &)
-
end
-
-
def switch_locale_with_article(&)
-
article = Article.find_by(slug: params[:article_slug])
-
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
-
render_404 && return if article.blank?
-
-
article_locale = if article.category.present?
-
article.category.locale
-
else
-
article.portal.default_locale
-
end
-
@locale = validate_and_get_locale(article_locale)
-
I18n.with_locale(@locale, &)
-
end
-
-
def allow_iframe_requests
-
response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled
-
end
-
-
def render_404
-
portal
-
render 'public/api/v1/portals/error/404', status: :not_found
-
end
-
-
def set_global_config
-
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
-
end
-
end
-
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
-
before_action :ensure_custom_domain_request, only: [:show, :index]
-
before_action :portal
-
before_action :set_category, only: [:show]
-
layout 'portal'
-
-
def index
-
@categories = @portal.categories.order(position: :asc)
-
end
-
-
def show; end
-
-
private
-
-
def set_category
-
@category = @portal.categories.find_by(locale: params[:locale], slug: params[:category_slug])
-
-
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
-
render_404 && return if @category.blank?
-
end
-
end
-
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
-
before_action :ensure_custom_domain_request, only: [:show]
-
before_action :portal
-
before_action :redirect_to_portal_with_locale, only: [:show]
-
layout 'portal'
-
-
def show; end
-
-
def sitemap
-
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
-
# if help_center_url does not contain a protocol, prepend it with https
-
@help_center_url = "https://#{@help_center_url}" unless @help_center_url.include?('://')
-
end
-
-
private
-
-
def portal
-
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
-
@locale = params[:locale] || @portal.default_locale
-
end
-
-
def redirect_to_portal_with_locale
-
return if params[:locale].present?
-
-
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
-
end
-
end
-
# TODO: we should switch to ActionController::API for the base classes
-
# One of the specs is failing when I tried doing that, lets revisit in future
-
class PublicController < ActionController::Base
-
include RequestExceptionHandler
-
skip_before_action :verify_authenticity_token
-
-
private
-
-
def ensure_custom_domain_request
-
domain = request.host
-
return if DomainHelper.chatwoot_domain?(domain)
-
-
@portal = ::Portal.find_by(custom_domain: domain)
-
return if @portal.present?
-
-
render json: {
-
error: "Domain: #{domain} is not registered with us. \
-
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
-
}, status: :unauthorized and return
-
end
-
end
-
class Shopify::CallbacksController < ApplicationController
-
include Shopify::IntegrationHelper
-
-
def show
-
verify_account!
-
-
@response = oauth_client.auth_code.get_token(
-
params[:code],
-
redirect_uri: '/shopify/callback'
-
)
-
-
handle_response
-
rescue StandardError => e
-
Rails.logger.error("Shopify callback error: #{e.message}")
-
redirect_to "#{redirect_uri}?error=true"
-
end
-
-
private
-
-
def verify_account!
-
@account_id = verify_shopify_token(params[:state])
-
raise StandardError, 'Invalid state parameter' if account.blank?
-
end
-
-
def handle_response
-
account.hooks.create!(
-
app_id: 'shopify',
-
access_token: parsed_body['access_token'],
-
status: 'enabled',
-
reference_id: params[:shop],
-
settings: {
-
scope: parsed_body['scope']
-
}
-
)
-
-
redirect_to shopify_integration_url
-
end
-
-
def parsed_body
-
@parsed_body ||= @response.response.parsed
-
end
-
-
def oauth_client
-
OAuth2::Client.new(
-
client_id,
-
client_secret,
-
{
-
site: "https://#{params[:shop]}",
-
authorize_url: '/admin/oauth/authorize',
-
token_url: '/admin/oauth/access_token'
-
}
-
)
-
end
-
-
def account
-
@account ||= Account.find(@account_id)
-
end
-
-
def account_id
-
@account_id ||= params[:state].split('_').first
-
end
-
-
def shopify_integration_url
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
-
end
-
-
def redirect_uri
-
return shopify_integration_url if account
-
-
ENV.fetch('FRONTEND_URL', nil)
-
end
-
end
-
class SlackUploadsController < ApplicationController
-
include Rails.application.routes.url_helpers
-
before_action :set_blob, only: [:show]
-
-
def show
-
if @blob
-
redirect_to blob_url
-
else
-
redirect_to avatar_url
-
end
-
end
-
-
private
-
-
def set_blob
-
@blob = ActiveStorage::Blob.find_by(key: params[:blob_key])
-
end
-
-
def blob_url
-
url_for(@blob.representation(resize_to_fill: [250, nil]))
-
end
-
-
def avatar_url
-
base_url = ENV.fetch('FRONTEND_URL', nil)
-
"#{base_url}/integrations/slack/#{params[:sender_type]}.png"
-
end
-
end
-
class SuperAdmin::AccessTokensController < SuperAdmin::ApplicationController
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
#
-
# def update
-
# super
-
# send_foo_updated_email(requested_resource)
-
# end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
# def scoped_resource
-
# if current_user.super_admin?
-
# resource_class
-
# else
-
# resource_class.with_less_stuff
-
# end
-
# end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
# def resource_params
-
# params.require(resource_class.model_name.param_key).
-
# permit(dashboard.permitted_attributes).
-
# transform_values { |value| value == "" ? nil : value }
-
# end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
end
-
class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
#
-
def create
-
resource = resource_class.new(resource_params)
-
authorize_resource(resource)
-
-
notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first
-
redirect_back(fallback_location: [namespace, resource.account], notice: notice)
-
end
-
-
def destroy
-
if requested_resource.destroy
-
flash[:notice] = translate_with_resource('destroy.success')
-
else
-
flash[:error] = requested_resource.errors.full_messages.join('<br/>')
-
end
-
redirect_back(fallback_location: [namespace, requested_resource.account])
-
end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
# def scoped_resource
-
# if current_user.super_admin?
-
# resource_class
-
# else
-
# resource_class.with_less_stuff
-
# end
-
# end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
# def resource_params
-
# params.require(resource_class.model_name.param_key).
-
# permit(dashboard.permitted_attributes).
-
# transform_values { |value| value == "" ? nil : value }
-
# end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
end
-
class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
#
-
# def update
-
# super
-
# send_foo_updated_email(requested_resource)
-
# end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
# def scoped_resource
-
# if current_user.super_admin?
-
# resource_class
-
# else
-
# resource_class.with_less_stuff
-
# end
-
# end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
def resource_params
-
permitted_params = super
-
permitted_params[:limits] = permitted_params[:limits].to_h.compact
-
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
-
permitted_params
-
end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
-
def seed
-
Internal::SeedAccountJob.perform_later(requested_resource)
-
# rubocop:disable Rails/I18nLocaleTexts
-
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
-
# rubocop:enable Rails/I18nLocaleTexts
-
end
-
-
def reset_cache
-
requested_resource.reset_cache_keys
-
# rubocop:disable Rails/I18nLocaleTexts
-
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
-
# rubocop:enable Rails/I18nLocaleTexts
-
end
-
-
def destroy
-
account = Account.find(params[:id])
-
-
DeleteObjectJob.perform_later(account) if account.present?
-
# rubocop:disable Rails/I18nLocaleTexts
-
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
-
# rubocop:enable Rails/I18nLocaleTexts
-
end
-
end
-
-
SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController')
-
class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
#
-
# def update
-
# super
-
# send_foo_updated_email(requested_resource)
-
# end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
# def scoped_resource
-
# if current_user.super_admin?
-
# resource_class
-
# else
-
# resource_class.with_less_stuff
-
# end
-
# end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
# def resource_params
-
# params.require(resource_class.model_name.param_key).
-
# permit(dashboard.permitted_attributes).
-
# transform_values { |value| value == "" ? nil : value }
-
# end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
-
def destroy_avatar
-
avatar = requested_resource.avatar
-
avatar.purge
-
redirect_back(fallback_location: super_admin_agent_bots_path)
-
end
-
-
def scoped_resource
-
resource_class.with_attached_avatar
-
end
-
end
-
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
-
before_action :set_config
-
before_action :allowed_configs
-
def show
-
# ref: https://github.com/rubocop/rubocop/issues/7767
-
# rubocop:disable Style/HashTransformValues
-
@app_config = InstallationConfig.where(name: @allowed_configs)
-
.pluck(:name, :serialized_value)
-
.map { |name, serialized_value| [name, serialized_value['value']] }
-
.to_h
-
# rubocop:enable Style/HashTransformValues
-
@installation_configs = ConfigLoader.new.general_configs.each_with_object({}) do |config_hash, result|
-
result[config_hash['name']] = config_hash.except('name')
-
end
-
end
-
-
def create
-
params['app_config'].each do |key, value|
-
next unless @allowed_configs.include?(key)
-
-
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
-
i.value = value
-
i.save!
-
end
-
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
-
end
-
-
private
-
-
def set_config
-
@config = params[:config] || 'general'
-
end
-
-
def allowed_configs
-
@allowed_configs = case @config
-
when 'facebook'
-
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
-
when 'shopify'
-
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
-
when 'microsoft'
-
%w[AZURE_APP_ID AZURE_APP_SECRET]
-
when 'email'
-
['MAILER_INBOUND_EMAIL_DOMAIN']
-
when 'linear'
-
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
-
when 'instagram'
-
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
-
else
-
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
-
end
-
end
-
end
-
-
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
-
# All Administrate controllers inherit from this
-
# `Administrate::ApplicationController`, making it the ideal place to put
-
# authentication logic or other before_actions.
-
#
-
# If you want to add pagination or other controller-level concerns,
-
# you're free to overwrite the RESTful controller actions.
-
class SuperAdmin::ApplicationController < Administrate::ApplicationController
-
include ActionView::Helpers::TagHelper
-
include ActionView::Context
-
-
helper_method :render_vue_component
-
# authenticiation done via devise : SuperAdmin Model
-
before_action :authenticate_super_admin!
-
-
# Override this value to specify the number of elements to display at a time
-
# on index pages. Defaults to 20.
-
# def records_per_page
-
# params[:per_page] || 20
-
# end
-
-
def order
-
@order ||= Administrate::Order.new(
-
params.fetch(resource_name, {}).fetch(:order, 'id'),
-
params.fetch(resource_name, {}).fetch(:direction, 'desc')
-
)
-
end
-
-
private
-
-
def render_vue_component(component_name, props = {})
-
html_options = {
-
id: 'app',
-
data: {
-
component_name: component_name,
-
props: props.to_json
-
}
-
}
-
content_tag(:div, '', html_options)
-
end
-
-
def invalid_action_perfomed
-
# rubocop:disable Rails/I18nLocaleTexts
-
flash[:error] = 'Invalid action performed'
-
# rubocop:enable Rails/I18nLocaleTexts
-
redirect_back(fallback_location: root_path)
-
end
-
end
-
class SuperAdmin::DashboardController < SuperAdmin::ApplicationController
-
include ActionView::Helpers::NumberHelper
-
-
def index
-
@data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a
-
@accounts_count = number_with_delimiter(Account.count)
-
@users_count = number_with_delimiter(User.count)
-
@inboxes_count = number_with_delimiter(Inbox.count)
-
@conversations_count = number_with_delimiter(Conversation.count)
-
end
-
end
-
# frozen_string_literal: true
-
-
class SuperAdmin::Devise::SessionsController < Devise::SessionsController
-
def new
-
self.resource = resource_class.new(sign_in_params)
-
end
-
-
def create
-
redirect_to(super_admin_session_path, flash: { error: @error_message }) && return unless valid_credentials?
-
-
sign_in(:super_admin, @super_admin)
-
flash.discard
-
redirect_to super_admin_users_path
-
end
-
-
def destroy
-
sign_out
-
flash.discard
-
redirect_to '/'
-
end
-
-
private
-
-
def valid_credentials?
-
@super_admin = SuperAdmin.find_by!(email: params[:super_admin][:email])
-
raise StandardError, 'Invalid Password' unless @super_admin.valid_password?(params[:super_admin][:password])
-
-
true
-
rescue StandardError => e
-
Rails.logger.error e.message
-
@error_message = 'Invalid credentials. Please try again.'
-
false
-
end
-
end
-
class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationController
-
rescue_from ActiveRecord::RecordNotUnique, :with => :invalid_action_perfomed
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
#
-
# def update
-
# super
-
# send_foo_updated_email(requested_resource)
-
# end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
def scoped_resource
-
resource_class.editable
-
end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
# def resource_params
-
# params.require(resource_class.model_name.param_key).
-
# permit(dashboard.permitted_attributes).
-
# transform_values { |value| value == "" ? nil : value }
-
# end
-
-
def resource_params
-
params.require(:installation_config)
-
.permit(:name, :value)
-
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
-
end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
end
-
class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
-
def show
-
@metrics = {}
-
chatwoot_version
-
sha
-
postgres_status
-
redis_metrics
-
chatwoot_edition
-
instance_meta
-
end
-
-
def chatwoot_edition
-
@metrics['Chatwoot edition'] = if ChatwootApp.enterprise?
-
'Enterprise'
-
elsif ChatwootApp.custom?
-
'Custom'
-
else
-
'Community'
-
end
-
end
-
-
def instance_meta
-
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
-
end
-
-
def chatwoot_version
-
@metrics['Chatwoot version'] = Chatwoot.config[:version]
-
end
-
-
def sha
-
@metrics['Git SHA'] = GIT_HASH
-
end
-
-
def postgres_status
-
@metrics['Postgres alive'] = if ActiveRecord::Base.connection.active?
-
'true'
-
else
-
'false'
-
end
-
end
-
-
def redis_metrics
-
r = Redis.new(Redis::Config.app)
-
if r.ping == 'PONG'
-
redis_server = r.info
-
@metrics['Redis alive'] = 'true'
-
@metrics['Redis version'] = redis_server['redis_version']
-
@metrics['Redis number of connected clients'] = redis_server['connected_clients']
-
@metrics["Redis 'maxclients' setting"] = redis_server['maxclients']
-
@metrics['Redis memory used'] = redis_server['used_memory_human']
-
@metrics['Redis memory peak'] = redis_server['used_memory_peak_human']
-
@metrics['Redis total memory available'] = redis_server['total_system_memory_human']
-
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
-
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
-
end
-
rescue Redis::CannotConnectError
-
@metrics['Redis alive'] = false
-
end
-
end
-
class SuperAdmin::PlatformAppsController < SuperAdmin::ApplicationController
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
#
-
# def update
-
# super
-
# send_foo_updated_email(requested_resource)
-
# end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
# def scoped_resource
-
# if current_user.super_admin?
-
# resource_class
-
# else
-
# resource_class.with_less_stuff
-
# end
-
# end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
# def resource_params
-
# params.require(resource_class.model_name.param_key).
-
# permit(dashboard.permitted_attributes).
-
# transform_values { |value| value == "" ? nil : value }
-
# end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
end
-
class SuperAdmin::SettingsController < SuperAdmin::ApplicationController
-
def show; end
-
-
def refresh
-
Internal::CheckNewVersionsJob.perform_now
-
# rubocop:disable Rails/I18nLocaleTexts
-
redirect_to super_admin_settings_path, notice: 'Instance status refreshed'
-
# rubocop:enable Rails/I18nLocaleTexts
-
end
-
end
-
class SuperAdmin::UsersController < SuperAdmin::ApplicationController
-
# Overwrite any of the RESTful controller actions to implement custom behavior
-
# For example, you may want to send an email after a foo is updated.
-
-
def create
-
resource = resource_class.new(resource_params)
-
authorize_resource(resource)
-
-
if resource.save
-
redirect_to super_admin_user_path(resource), notice: translate_with_resource('create.success')
-
else
-
notice = resource.errors.full_messages.first
-
redirect_to new_super_admin_user_path, notice: notice
-
end
-
end
-
#
-
# def update
-
# super
-
# send_foo_updated_email(requested_resource)
-
# end
-
-
# Override this method to specify custom lookup behavior.
-
# This will be used to set the resource for the `show`, `edit`, and `update`
-
# actions.
-
#
-
# def find_resource(param)
-
# Foo.find_by!(slug: param)
-
# end
-
-
# The result of this lookup will be available as `requested_resource`
-
-
# Override this if you have certain roles that require a subset
-
# this will be used to set the records shown on the `index` action.
-
#
-
# def scoped_resource
-
# if current_user.super_admin?
-
# resource_class
-
# else
-
# resource_class.with_less_stuff
-
# end
-
# end
-
-
# Override `resource_params` if you want to transform the submitted
-
# data before it's persisted. For example, the following would turn all
-
# empty values into nil values. It uses other APIs such as `resource_class`
-
# and `dashboard`:
-
#
-
-
def destroy_avatar
-
avatar = requested_resource.avatar
-
avatar.purge
-
redirect_back(fallback_location: super_admin_users_path)
-
end
-
-
def scoped_resource
-
resource_class.with_attached_avatar
-
end
-
-
def resource_params
-
permitted_params = super
-
permitted_params.delete(:password) if permitted_params[:password].blank?
-
permitted_params
-
end
-
-
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
-
# for more information
-
def find_resource(param)
-
super.becomes(User)
-
end
-
end
-
class Survey::ResponsesController < ActionController::Base
-
before_action :set_global_config
-
def show; end
-
-
private
-
-
def set_global_config
-
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
-
end
-
end
-
class SwaggerController < ApplicationController
-
def respond
-
if Rails.env.development? || Rails.env.test?
-
render inline: Rails.root.join('swagger', derived_path).read
-
else
-
head :not_found
-
end
-
end
-
-
private
-
-
def derived_path
-
params[:path] ||= 'index.html'
-
path = Rack::Utils.clean_path_info(params[:path])
-
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
-
path
-
end
-
end
-
class Twilio::CallbackController < ApplicationController
-
def create
-
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
-
-
head :no_content
-
end
-
-
private
-
-
def permitted_params # rubocop:disable Metrics/MethodLength
-
params.permit(
-
:ApiVersion,
-
:SmsSid,
-
:From,
-
:ToState,
-
:ToZip,
-
:AccountSid,
-
:MessageSid,
-
:FromCountry,
-
:ToCity,
-
:FromCity,
-
:To,
-
:FromZip,
-
:Body,
-
:ToCountry,
-
:FromState,
-
:MediaUrl0,
-
:MediaContentType0,
-
:MessagingServiceSid
-
)
-
end
-
end
-
class Twilio::DeliveryStatusController < ApplicationController
-
def create
-
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
-
-
head :no_content
-
end
-
-
private
-
-
def permitted_params
-
params.permit(
-
:AccountSid,
-
:From,
-
:MessageSid,
-
:MessagingServiceSid,
-
:MessageStatus,
-
:ErrorCode,
-
:ErrorMessage
-
)
-
end
-
end
-
class Twitter::BaseController < ApplicationController
-
include TwitterConcern
-
end
-
class Twitter::CallbacksController < Twitter::BaseController
-
include TwitterConcern
-
-
def show
-
return redirect_to twitter_app_redirect_url if permitted_params[:denied]
-
-
@response = ensure_access_token
-
return redirect_to twitter_app_redirect_url if @response.status != '200'
-
-
ActiveRecord::Base.transaction do
-
inbox = create_inbox
-
::Redis::Alfred.delete(permitted_params[:oauth_token])
-
::Twitter::WebhookSubscribeService.new(inbox_id: inbox.id).perform
-
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
-
end
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
redirect_to twitter_app_redirect_url
-
end
-
-
private
-
-
def parsed_body
-
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
-
end
-
-
def account_id
-
::Redis::Alfred.get(permitted_params[:oauth_token])
-
end
-
-
def account
-
@account ||= Account.find(account_id)
-
end
-
-
def twitter_app_redirect_url
-
app_new_twitter_inbox_url(account_id: account.id)
-
end
-
-
def ensure_access_token
-
twitter_client.access_token(
-
oauth_token: permitted_params[:oauth_token],
-
oauth_verifier: permitted_params[:oauth_verifier]
-
)
-
end
-
-
def create_inbox
-
twitter_profile = account.twitter_profiles.create!(
-
twitter_access_token: parsed_body['oauth_token'],
-
twitter_access_token_secret: parsed_body['oauth_token_secret'],
-
profile_id: parsed_body['user_id']
-
)
-
inbox = account.inboxes.create!(
-
name: parsed_body['screen_name'],
-
channel: twitter_profile
-
)
-
save_profile_image(inbox)
-
inbox
-
end
-
-
def save_profile_image(inbox)
-
response = twitter_client.user_show(screen_name: inbox.name)
-
-
return unless response.status.to_i == 200
-
-
parsed_user_profile = response.body
-
-
::Avatar::AvatarFromUrlJob.perform_later(inbox, parsed_user_profile['profile_image_url_https'])
-
end
-
-
def permitted_params
-
params.permit(:oauth_token, :oauth_verifier, :denied)
-
end
-
end
-
class Webhooks::InstagramController < ActionController::API
-
include MetaTokenVerifyConcern
-
-
def events
-
Rails.logger.info('Instagram webhook received events')
-
if params['object'].casecmp('instagram').zero?
-
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
-
render json: :ok
-
else
-
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
-
head :unprocessable_entity
-
end
-
end
-
-
private
-
-
def valid_token?(token)
-
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
-
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
-
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
-
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
-
end
-
end
-
class Webhooks::LineController < ActionController::API
-
def process_payload
-
Webhooks::LineEventsJob.perform_later(params: params.to_unsafe_hash, signature: request.headers['x-line-signature'], post_body: request.raw_post)
-
head :ok
-
end
-
end
-
class Webhooks::SmsController < ActionController::API
-
def process_payload
-
Webhooks::SmsEventsJob.perform_later(params['_json']&.first&.to_unsafe_hash)
-
head :ok
-
end
-
end
-
class Webhooks::TelegramController < ActionController::API
-
def process_payload
-
Webhooks::TelegramEventsJob.perform_later(params.to_unsafe_hash)
-
head :ok
-
end
-
end
-
class Webhooks::WhatsappController < ActionController::API
-
include MetaTokenVerifyConcern
-
-
def process_payload
-
if inactive_whatsapp_number?
-
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
-
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
-
return
-
end
-
-
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
-
head :ok
-
end
-
-
private
-
-
def valid_token?(token)
-
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
-
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
-
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
-
end
-
-
def inactive_whatsapp_number?
-
phone_number = params[:phone_number]
-
return false if phone_number.blank?
-
-
inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s
-
return false if inactive_numbers.blank?
-
-
inactive_numbers_array = inactive_numbers.split(',').map(&:strip)
-
inactive_numbers_array.include?(phone_number)
-
end
-
end
-
class WidgetTestsController < ActionController::Base
-
before_action :ensure_web_widget
-
before_action :ensure_widget_position
-
before_action :ensure_widget_type
-
before_action :ensure_widget_style
-
before_action :ensure_dark_mode
-
-
def index
-
render
-
end
-
-
private
-
-
def ensure_widget_style
-
@widget_style = params[:widget_style] || 'standard'
-
end
-
-
def ensure_dark_mode
-
@dark_mode = params[:dark_mode] || 'light'
-
end
-
-
def ensure_widget_position
-
@widget_position = params[:position] || 'left'
-
end
-
-
def ensure_widget_type
-
@widget_type = params[:type] || 'expanded_bubble'
-
end
-
-
def inbox_id
-
@inbox_id ||= params[:inbox_id] || Channel::WebWidget.first.inbox.id
-
end
-
-
def ensure_web_widget
-
@inbox = Inbox.find(inbox_id)
-
@web_widget = @inbox.channel
-
end
-
end
-
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
-
class WidgetsController < ActionController::Base
-
include WidgetHelper
-
-
before_action :set_global_config
-
before_action :set_web_widget
-
before_action :ensure_account_is_active
-
before_action :ensure_location_is_supported
-
before_action :set_token
-
before_action :set_contact
-
before_action :build_contact
-
after_action :allow_iframe_requests
-
-
private
-
-
def set_global_config
-
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED')
-
end
-
-
def set_web_widget
-
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
-
rescue ActiveRecord::RecordNotFound
-
Rails.logger.error('web widget does not exist')
-
render json: { error: 'web widget does not exist' }, status: :not_found
-
end
-
-
def set_token
-
@token = permitted_params[:cw_conversation]
-
@auth_token_params = if @token.present?
-
::Widget::TokenService.new(token: @token).decode_token
-
else
-
{}
-
end
-
end
-
-
def set_contact
-
return if @auth_token_params[:source_id].nil?
-
-
@contact_inbox = ::ContactInbox.find_by(
-
inbox_id: @web_widget.inbox.id,
-
source_id: @auth_token_params[:source_id]
-
)
-
-
@contact = @contact_inbox&.contact
-
end
-
-
def build_contact
-
return if @contact.present?
-
-
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
-
@contact = @contact_inbox.contact
-
end
-
-
def ensure_account_is_active
-
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
-
end
-
-
def ensure_location_is_supported; end
-
-
def additional_attributes
-
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
-
{ created_at_ip: request.remote_ip }
-
else
-
{}
-
end
-
end
-
-
def permitted_params
-
params.permit(:website_token, :cw_conversation)
-
end
-
-
def allow_iframe_requests
-
response.headers.delete('X-Frame-Options')
-
end
-
end
-
-
WidgetsController.prepend_mod_with('WidgetsController')
-
require 'administrate/base_dashboard'
-
-
class AccessTokenDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
ATTRIBUTE_TYPES = {
-
owner: Field::Polymorphic,
-
id: Field::Number,
-
token: SecretField,
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime
-
}.freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
owner
-
id
-
token
-
created_at
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
SHOW_PAGE_ATTRIBUTES = %i[
-
token
-
].freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
FORM_ATTRIBUTES = %i[
-
owner
-
token
-
].freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {
-
user: ->(resources) { resources.where(owner_type: 'User') },
-
agent_bot: ->(resources) { resources.where(owner_type: 'AgentBot') },
-
platform_app: ->(resources) { resources.where(owner_type: 'PlatformApp') }
-
}.freeze
-
-
# Overwrite this method to customize how access tokens are displayed
-
# across all pages of the admin dashboard.
-
#
-
# def display_resource(access_token)
-
# "AccessToken ##{access_token.id}"
-
# end
-
end
-
require 'administrate/base_dashboard'
-
-
class AccountDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
-
enterprise_attribute_types = if ChatwootApp.enterprise?
-
attributes = {
-
limits: AccountLimitsField
-
}
-
-
# Only show manually managed features in Chatwoot Cloud deployment
-
attributes[:manually_managed_features] = ManuallyManagedFeaturesField if ChatwootApp.chatwoot_cloud?
-
-
# Add all_features last so it appears after manually_managed_features
-
attributes[:all_features] = AccountFeaturesField
-
-
attributes
-
else
-
{}
-
end
-
-
ATTRIBUTE_TYPES = {
-
id: Field::Number.with_options(searchable: true),
-
name: Field::String.with_options(searchable: true),
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime,
-
users: CountField,
-
conversations: CountField,
-
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
-
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
-
account_users: Field::HasMany,
-
custom_attributes: Field::String
-
}.merge(enterprise_attribute_types).freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
id
-
name
-
locale
-
users
-
conversations
-
status
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
enterprise_show_page_attributes = if ChatwootApp.enterprise?
-
attrs = %i[custom_attributes limits]
-
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
-
attrs << :all_features
-
attrs
-
else
-
[]
-
end
-
SHOW_PAGE_ATTRIBUTES = (%i[
-
id
-
name
-
created_at
-
updated_at
-
locale
-
status
-
conversations
-
account_users
-
] + enterprise_show_page_attributes).freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
enterprise_form_attributes = if ChatwootApp.enterprise?
-
attrs = %i[limits]
-
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
-
attrs << :all_features
-
attrs
-
else
-
[]
-
end
-
FORM_ATTRIBUTES = (%i[
-
name
-
locale
-
status
-
] + enterprise_form_attributes).freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {
-
active: ->(resources) { resources.where(status: :active) },
-
suspended: ->(resources) { resources.where(status: :suspended) },
-
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) },
-
marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") }
-
}.freeze
-
-
# Overwrite this method to customize how accounts are displayed
-
# across all pages of the admin dashboard.
-
#
-
def display_resource(account)
-
"##{account.id} #{account.name}"
-
end
-
-
# We do not use the action parameter but we still need to define it
-
# to prevent an error from being raised (wrong number of arguments)
-
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
-
def permitted_attributes(action)
-
attrs = super + [limits: {}]
-
-
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
-
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
-
-
attrs
-
end
-
end
-
require 'administrate/base_dashboard'
-
-
class AccountUserDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
ATTRIBUTE_TYPES = {
-
account: Field::BelongsToSearch.with_options(class_name: 'Account', searchable: true, searchable_field: [:name, :id], order: 'id DESC'),
-
user: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'),
-
inviter: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'),
-
id: Field::Number,
-
role: Field::Select.with_options(collection: AccountUser.roles.keys),
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime
-
}.freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
account
-
user
-
inviter
-
role
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
SHOW_PAGE_ATTRIBUTES = %i[
-
account
-
user
-
inviter
-
id
-
role
-
created_at
-
updated_at
-
].freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
FORM_ATTRIBUTES = %i[
-
account
-
user
-
role
-
].freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {}.freeze
-
-
# Overwrite this method to customize how account users are displayed
-
# across all pages of the admin dashboard.
-
#
-
def display_resource(account_user)
-
"AccountUser ##{account_user.id}"
-
end
-
end
-
require 'administrate/base_dashboard'
-
-
class AgentBotDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
ATTRIBUTE_TYPES = {
-
access_token: Field::HasOne,
-
avatar_url: AvatarField,
-
avatar: Field::ActiveStorage.with_options(
-
destroy_url: proc do |_namespace, _resource, attachment|
-
[:avatar_super_admin_agent_bot, { attachment_id: attachment.id }]
-
end
-
),
-
id: Field::Number,
-
name: Field::String,
-
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
-
description: Field::String,
-
outgoing_url: Field::String,
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime
-
}.freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
id
-
avatar_url
-
account
-
name
-
outgoing_url
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
SHOW_PAGE_ATTRIBUTES = %i[
-
id
-
avatar_url
-
account
-
name
-
description
-
outgoing_url
-
access_token
-
].freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
FORM_ATTRIBUTES = %i[
-
name
-
avatar
-
account
-
description
-
outgoing_url
-
].freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {}.freeze
-
-
# Overwrite this method to customize how agent bots are displayed
-
# across all pages of the admin dashboard.
-
#
-
# def display_resource(agent_bot)
-
# "AgentBot ##{agent_bot.id}"
-
# end
-
end
-
require 'administrate/base_dashboard'
-
-
class InstallationConfigDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
ATTRIBUTE_TYPES = {
-
id: Field::Number,
-
name: Field::String,
-
value: SerializedField,
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime
-
}.freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
id
-
name
-
value
-
created_at
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
SHOW_PAGE_ATTRIBUTES = %i[
-
id
-
name
-
value
-
created_at
-
updated_at
-
].freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
FORM_ATTRIBUTES = %i[
-
name
-
value
-
].freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {}.freeze
-
-
# Overwrite this method to customize how installation configs are displayed
-
# across all pages of the admin dashboard.
-
#
-
# def display_resource(installation_config)
-
# "InstallationConfig ##{installation_config.id}"
-
# end
-
end
-
require 'administrate/base_dashboard'
-
-
class PlatformAppDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
ATTRIBUTE_TYPES = {
-
access_token: Field::HasOne,
-
id: Field::Number,
-
name: Field::String,
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime
-
}.freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
id
-
name
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
SHOW_PAGE_ATTRIBUTES = %i[
-
id
-
name
-
created_at
-
updated_at
-
access_token
-
].freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
FORM_ATTRIBUTES = %i[
-
name
-
].freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {}.freeze
-
-
# Overwrite this method to customize how platform apps are displayed
-
# across all pages of the admin dashboard.
-
#
-
# def display_resource(platform_app)
-
# "PlatformApp ##{platform_app.id}"
-
# end
-
end
-
require 'administrate/base_dashboard'
-
-
class UserDashboard < Administrate::BaseDashboard
-
# ATTRIBUTE_TYPES
-
# a hash that describes the type of each of the model's fields.
-
#
-
# Each different type represents an Administrate::Field object,
-
# which determines how the attribute is displayed
-
# on pages throughout the dashboard.
-
ATTRIBUTE_TYPES = {
-
account_users: Field::HasMany,
-
id: Field::Number.with_options(searchable: true),
-
avatar_url: AvatarField,
-
avatar: Field::ActiveStorage.with_options(
-
destroy_url: proc do |_namespace, _resource, attachment|
-
[:avatar_super_admin_user, { attachment_id: attachment.id }]
-
end
-
),
-
provider: Field::String,
-
uid: Field::String,
-
password: Field::Password,
-
sign_in_count: Field::Number,
-
current_sign_in_at: Field::DateTime,
-
last_sign_in_at: Field::DateTime,
-
current_sign_in_ip: Field::String,
-
last_sign_in_ip: Field::String,
-
confirmation_token: Field::String,
-
confirmed_at: Field::DateTime,
-
confirmation_sent_at: Field::DateTime,
-
unconfirmed_email: Field::String,
-
name: Field::String.with_options(searchable: true),
-
display_name: Field::String,
-
email: Field::String.with_options(searchable: true),
-
tokens: Field::String.with_options(searchable: false),
-
created_at: Field::DateTime,
-
updated_at: Field::DateTime,
-
pubsub_token: Field::String,
-
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
-
accounts: CountField,
-
access_token: Field::HasOne
-
}.freeze
-
-
# COLLECTION_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's index page.
-
#
-
# By default, it's limited to four items to reduce clutter on index pages.
-
# Feel free to add, remove, or rearrange items.
-
COLLECTION_ATTRIBUTES = %i[
-
id
-
avatar_url
-
name
-
email
-
accounts
-
type
-
].freeze
-
-
# SHOW_PAGE_ATTRIBUTES
-
# an array of attributes that will be displayed on the model's show page.
-
SHOW_PAGE_ATTRIBUTES = %i[
-
id
-
avatar_url
-
unconfirmed_email
-
name
-
type
-
display_name
-
email
-
created_at
-
updated_at
-
confirmed_at
-
account_users
-
access_token
-
].freeze
-
-
# FORM_ATTRIBUTES
-
# an array of attributes that will be displayed
-
# on the model's form (`new` and `edit`) pages.
-
FORM_ATTRIBUTES = %i[
-
name
-
avatar
-
display_name
-
email
-
password
-
confirmed_at
-
type
-
].freeze
-
-
# COLLECTION_FILTERS
-
# a hash that defines filters that can be used while searching via the search
-
# field of the dashboard.
-
#
-
# For example to add an option to search for open resources by typing "open:"
-
# in the search field:
-
#
-
# COLLECTION_FILTERS = {
-
# open: ->(resources) { resources.where(open: true) }
-
# }.freeze
-
COLLECTION_FILTERS = {
-
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
-
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
-
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
-
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
-
}.freeze
-
-
# Overwrite this method to customize how users are displayed
-
# across all pages of the admin dashboard.
-
#
-
def display_resource(user)
-
"##{user.id} #{user.name}"
-
end
-
end
-
1
class AsyncDispatcher < BaseDispatcher
-
1
def dispatch(event_name, timestamp, data)
-
48
EventDispatcherJob.perform_later(event_name, timestamp, data)
-
end
-
-
1
def publish_event(event_name, timestamp, data)
-
event_object = Events::Base.new(event_name, timestamp, data)
-
publish(event_object.method_name, event_object)
-
end
-
-
1
def listeners
-
[
-
1
AutomationRuleListener.instance,
-
CampaignListener.instance,
-
CsatSurveyListener.instance,
-
HookListener.instance,
-
InstallationWebhookListener.instance,
-
NotificationListener.instance,
-
ParticipationListener.instance,
-
ReportingEventListener.instance,
-
WebhookListener.instance
-
]
-
end
-
end
-
-
1
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')
-
1
class BaseDispatcher
-
1
include Wisper::Publisher
-
-
1
def listeners
-
[]
-
end
-
-
1
def load_listeners
-
14
listeners.each { |listener| subscribe(listener) }
-
end
-
end
-
1
class Dispatcher
-
1
include Singleton
-
-
1
attr_reader :async_dispatcher, :sync_dispatcher
-
-
1
def self.dispatch(event_name, timestamp, data, async = false)
-
Rails.configuration.dispatcher.dispatch(event_name, timestamp, data, async)
-
end
-
-
1
def initialize
-
1
@sync_dispatcher = SyncDispatcher.new
-
1
@async_dispatcher = AsyncDispatcher.new
-
end
-
-
1
def dispatch(event_name, timestamp, data, _async = false)
-
48
@sync_dispatcher.dispatch(event_name, timestamp, data)
-
48
@async_dispatcher.dispatch(event_name, timestamp, data)
-
end
-
-
1
def load_listeners
-
1
@sync_dispatcher.load_listeners
-
1
@async_dispatcher.load_listeners
-
end
-
end
-
1
class SyncDispatcher < BaseDispatcher
-
1
def dispatch(event_name, timestamp, data)
-
48
event_object = Events::Base.new(event_name, timestamp, data)
-
48
publish(event_object.method_name, event_object)
-
end
-
-
1
def listeners
-
1
[ActionCableListener.instance, AgentBotListener.instance]
-
end
-
end
-
1
class AccountDrop < BaseDrop
-
1
def name
-
@obj.try(:name)
-
end
-
end
-
1
class BaseDrop < Liquid::Drop
-
1
def initialize(obj)
-
15
@obj = obj
-
end
-
-
1
def id
-
@obj.try(:id)
-
end
-
-
1
def name
-
@obj.try(:name)
-
end
-
end
-
1
class ContactDrop < BaseDrop
-
1
def name
-
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
-
end
-
-
1
def email
-
@obj.try(:email)
-
end
-
-
1
def phone_number
-
@obj.try(:phone_number)
-
end
-
-
1
def first_name
-
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
-
end
-
-
1
def last_name
-
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
-
end
-
-
1
def custom_attribute
-
custom_attributes = @obj.try(:custom_attributes) || {}
-
custom_attributes.transform_keys(&:to_s)
-
end
-
end
-
1
class ConversationDrop < BaseDrop
-
1
include MessageFormatHelper
-
-
1
def display_id
-
@obj.try(:display_id)
-
end
-
-
1
def contact_name
-
@obj.try(:contact).name.try(:capitalize) || 'Customer'
-
end
-
-
1
def recent_messages
-
@obj.try(:recent_messages).map do |message|
-
{
-
'sender' => message_sender_name(message.sender),
-
'content' => render_message_content(transform_user_mention_content(message.content)),
-
'attachments' => message.attachments.map(&:file_url)
-
}
-
end
-
end
-
-
1
def custom_attribute
-
custom_attributes = @obj.try(:custom_attributes) || {}
-
custom_attributes.transform_keys(&:to_s)
-
end
-
-
1
private
-
-
1
def message_sender_name(sender)
-
return 'Bot' if sender.blank?
-
return contact_name if sender.instance_of?(Contact)
-
-
sender&.available_name || sender&.name
-
end
-
end
-
1
class InboxDrop < BaseDrop
-
1
def name
-
@obj.try(:name)
-
end
-
end
-
class MessageDrop < BaseDrop
-
include MessageFormatHelper
-
-
def sender_display_name
-
@obj.sender.try(:available_name)
-
end
-
-
def text_content
-
content = @obj.try(:content) || ''
-
render_message_content(transform_user_mention_content(content))
-
end
-
end
-
1
class UserDrop < BaseDrop
-
1
def name
-
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
-
end
-
-
1
def available_name
-
@obj.try(:available_name)
-
end
-
-
1
def first_name
-
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
-
end
-
-
1
def last_name
-
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
-
end
-
end
-
require 'administrate/field/base'
-
-
class AvatarField < Administrate::Field::Base
-
def avatar_url
-
return data.presence if data.presence
-
-
resource.is_a?(User) ? '/assets/administrate/user/avatar.png' : '/assets/administrate/bot/avatar.png'
-
end
-
end
-
require 'administrate/field/base'
-
-
class CountField < Administrate::Field::Base
-
def to_s
-
data.count
-
end
-
end
-
require 'administrate/field/base'
-
-
class SecretField < Administrate::Field::String
-
end
-
require 'administrate/field/base'
-
-
class SerializedField < Administrate::Field::Base
-
def to_s
-
hash? ? data.as_json : data.to_s
-
end
-
-
def hash?
-
data.is_a? Hash
-
end
-
-
def array?
-
data.is_a? Array
-
end
-
end
-
class ConversationFinder
-
attr_reader :current_user, :current_account, :params
-
-
DEFAULT_STATUS = 'open'.freeze
-
SORT_OPTIONS = {
-
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
-
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
-
'created_at_asc' => %w[sort_on_created_at asc],
-
'created_at_desc' => %w[sort_on_created_at desc],
-
'priority_asc' => %w[sort_on_priority asc],
-
'priority_desc' => %w[sort_on_priority desc],
-
'waiting_since_asc' => %w[sort_on_waiting_since asc],
-
'waiting_since_desc' => %w[sort_on_waiting_since desc],
-
-
# To be removed in v3.5.0
-
'latest' => %w[sort_on_last_activity_at desc],
-
'sort_on_created_at' => %w[sort_on_created_at asc],
-
'sort_on_priority' => %w[sort_on_priority desc],
-
'sort_on_waiting_since' => %w[sort_on_waiting_since asc]
-
}.with_indifferent_access
-
# assumptions
-
# inbox_id if not given, take from all conversations, else specific to inbox
-
# assignee_type if not given, take 'all'
-
# conversation_status if not given, take 'open'
-
-
# response of this class will be of type
-
# {conversations: [array of conversations], count: {open: count, resolved: count}}
-
-
# params
-
# assignee_type, inbox_id, :status
-
-
def initialize(current_user, params)
-
@current_user = current_user
-
@current_account = current_user.account
-
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
-
@params = params
-
end
-
-
def perform
-
set_up
-
-
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
-
assigned_count = all_count - unassigned_count
-
-
filter_by_assignee_type
-
-
{
-
conversations: conversations,
-
count: {
-
mine_count: mine_count,
-
assigned_count: assigned_count,
-
unassigned_count: unassigned_count,
-
all_count: all_count
-
}
-
}
-
end
-
-
private
-
-
def set_up
-
set_inboxes
-
set_team
-
set_assignee_type
-
-
find_all_conversations
-
filter_by_status unless params[:q]
-
filter_by_team
-
filter_by_labels
-
filter_by_query
-
filter_by_source_id
-
end
-
-
def set_inboxes
-
@inbox_ids = if params[:inbox_id]
-
@current_user.assigned_inboxes.where(id: params[:inbox_id])
-
else
-
@current_user.assigned_inboxes.pluck(:id)
-
end
-
end
-
-
def set_assignee_type
-
@assignee_type = params[:assignee_type]
-
end
-
-
def set_team
-
@team = current_account.teams.find(params[:team_id]) if params[:team_id]
-
end
-
-
def find_conversation_by_inbox
-
@conversations = current_account.conversations
-
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
-
end
-
-
def find_all_conversations
-
find_conversation_by_inbox
-
# Apply permission-based filtering
-
@conversations = Conversations::PermissionFilterService.new(
-
@conversations,
-
current_user,
-
current_account
-
).perform
-
filter_by_conversation_type if params[:conversation_type]
-
@conversations
-
end
-
-
def filter_by_assignee_type
-
case @assignee_type
-
when 'me'
-
@conversations = @conversations.assigned_to(current_user)
-
when 'unassigned'
-
@conversations = @conversations.unassigned
-
when 'assigned'
-
@conversations = @conversations.assigned
-
end
-
@conversations
-
end
-
-
def filter_by_conversation_type
-
case @params[:conversation_type]
-
when 'mention'
-
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
-
@conversations = @conversations.where(id: conversation_ids)
-
when 'participating'
-
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
-
when 'unattended'
-
@conversations = @conversations.unattended
-
end
-
@conversations
-
end
-
-
def filter_by_query
-
return unless params[:q]
-
-
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
-
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
-
.where(messages: { message_type: allowed_message_types }).includes(:messages)
-
.where('messages.content ILIKE :search', search: "%#{params[:q]}%")
-
.where(messages: { message_type: allowed_message_types })
-
end
-
-
def filter_by_status
-
return if params[:status] == 'all'
-
-
@conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS)
-
end
-
-
def filter_by_team
-
return unless @team
-
-
@conversations = @conversations.where(team: @team)
-
end
-
-
def filter_by_labels
-
return unless params[:labels]
-
-
@conversations = @conversations.tagged_with(params[:labels], any: true)
-
end
-
-
def filter_by_source_id
-
return unless params[:source_id]
-
-
@conversations = @conversations.joins(:contact_inbox)
-
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
-
end
-
-
def set_count_for_all_conversations
-
[
-
@conversations.assigned_to(current_user).count,
-
@conversations.unassigned.count,
-
@conversations.count
-
]
-
end
-
-
def current_page
-
params[:page] || 1
-
end
-
-
def conversations_base_query
-
@conversations.includes(
-
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
-
)
-
end
-
-
def conversations
-
@conversations = conversations_base_query
-
-
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
-
@conversations = @conversations.send(sort_by, sort_order)
-
-
if params[:updated_within].present?
-
@conversations.where('conversations.updated_at > ?', Time.zone.now - params[:updated_within].to_i.seconds)
-
else
-
@conversations.page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
-
end
-
end
-
end
-
ConversationFinder.prepend_mod_with('ConversationFinder')
-
class EmailChannelFinder
-
include EmailHelper
-
-
def initialize(email_object)
-
@email_object = email_object
-
end
-
-
def perform
-
channel = nil
-
-
recipient_mails.each do |email|
-
normalized_email = normalize_email_with_plus_addressing(email)
-
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
-
-
break if channel.present?
-
end
-
channel
-
end
-
-
def recipient_mails
-
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
-
recipient_addresses.flatten.compact
-
end
-
end
-
class MessageFinder
-
def initialize(conversation, params)
-
@conversation = conversation
-
@params = params
-
end
-
-
def perform
-
current_messages
-
end
-
-
private
-
-
def conversation_messages
-
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
-
end
-
-
def messages
-
return conversation_messages if @params[:filter_internal_messages].blank?
-
-
conversation_messages.where.not('private = ? OR message_type = ?', true, 2)
-
end
-
-
def current_messages
-
if @params[:after].present? && @params[:before].present?
-
messages_between(@params[:after].to_i, @params[:before].to_i)
-
elsif @params[:before].present?
-
messages_before(@params[:before].to_i)
-
elsif @params[:after].present?
-
messages_after(@params[:after].to_i)
-
else
-
messages_latest
-
end
-
end
-
-
def messages_after(after_id)
-
messages.reorder('created_at asc').where('id > ?', after_id).limit(100)
-
end
-
-
def messages_before(before_id)
-
messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse
-
end
-
-
def messages_between(after_id, before_id)
-
messages.reorder('created_at asc').where('id >= ? AND id < ?', after_id, before_id).limit(1000)
-
end
-
-
def messages_latest
-
messages.reorder('created_at desc').limit(20).reverse
-
end
-
end
-
class NotificationFinder
-
attr_reader :current_user, :current_account, :params
-
-
RESULTS_PER_PAGE = 15
-
-
def initialize(current_user, current_account, params = {})
-
@current_user = current_user
-
@current_account = current_account
-
@params = params
-
set_up
-
end
-
-
def notifications
-
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: sort_order)
-
end
-
-
def unread_count
-
@notifications.where(read_at: nil).count
-
end
-
-
def count
-
@notifications.count
-
end
-
-
private
-
-
def set_up
-
find_all_notifications
-
filter_snoozed_notifications
-
fitler_read_notifications
-
end
-
-
def find_all_notifications
-
@notifications = current_user.notifications.where(account_id: @current_account.id)
-
end
-
-
def filter_snoozed_notifications
-
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
-
end
-
-
def fitler_read_notifications
-
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
-
end
-
-
def type_included?(type)
-
(params[:includes] || []).include?(type)
-
end
-
-
def current_page
-
params[:page] || 1
-
end
-
-
def sort_order
-
params[:sort_order] || :desc
-
end
-
end
-
1
module Api::BaseHelper
-
end
-
1
module Api::V1::AgentsHelper
-
end
-
1
module Api::V1::CannedResponsesHelper
-
end
-
1
module Api::V1::ConversationsHelper
-
end
-
1
module Api::V1::InboxesHelper
-
1
def inbox_name(channel)
-
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
-
-
permitted_params[:name]
-
end
-
-
1
def validate_email_channel(attributes)
-
channel_data = permitted_params(attributes)[:channel]
-
-
validate_imap(channel_data)
-
validate_smtp(channel_data)
-
end
-
-
1
private
-
-
1
def validate_imap(channel_data)
-
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
-
-
Mail.defaults do
-
retriever_method :imap, { address: channel_data[:imap_address],
-
port: channel_data[:imap_port],
-
user_name: channel_data[:imap_login],
-
password: channel_data[:imap_password],
-
enable_ssl: channel_data[:imap_enable_ssl] }
-
end
-
-
check_imap_connection(channel_data)
-
end
-
-
1
def validate_smtp(channel_data)
-
return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled]
-
-
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
-
-
set_smtp_encryption(channel_data, smtp)
-
check_smtp_connection(channel_data, smtp)
-
end
-
-
1
def check_imap_connection(channel_data)
-
Mail.connection {} # rubocop:disable:block
-
rescue SocketError => e
-
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
-
rescue Net::IMAP::NoResponseError => e
-
raise StandardError, I18n.t('errors.inboxes.imap.no_response_error')
-
rescue Errno::EHOSTUNREACH => e
-
raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error')
-
rescue Net::OpenTimeout => e
-
raise StandardError,
-
I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port])
-
rescue Net::IMAP::Error => e
-
raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error')
-
rescue StandardError => e
-
raise StandardError, e.message
-
ensure
-
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
-
end
-
-
1
def check_smtp_connection(channel_data, smtp)
-
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
-
channel_data[:smtp_authentication]&.to_sym || :login)
-
smtp.finish
-
end
-
-
1
def set_smtp_encryption(channel_data, smtp)
-
if channel_data[:smtp_enable_ssl_tls]
-
set_enable_tls(channel_data, smtp)
-
elsif channel_data[:smtp_enable_starttls_auto]
-
set_enable_starttls_auto(channel_data, smtp)
-
end
-
end
-
-
1
def set_enable_starttls_auto(channel_data, smtp)
-
return unless smtp.respond_to?(:enable_starttls_auto)
-
-
if channel_data[:smtp_openssl_verify_mode]
-
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
-
smtp.enable_starttls_auto(context)
-
else
-
smtp.enable_starttls_auto
-
end
-
end
-
-
1
def set_enable_tls(channel_data, smtp)
-
return unless smtp.respond_to?(:enable_tls)
-
-
if channel_data[:smtp_openssl_verify_mode]
-
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
-
smtp.enable_tls(context)
-
else
-
smtp.enable_tls
-
end
-
end
-
-
1
def enable_openssl_mode(smtp_openssl_verify_mode)
-
openssl_verify_mode = "OpenSSL::SSL::VERIFY_#{smtp_openssl_verify_mode.upcase}".constantize if smtp_openssl_verify_mode.is_a?(String)
-
context = Net::SMTP.default_ssl_context
-
context.verify_mode = openssl_verify_mode
-
context
-
end
-
-
1
def account_channels_method
-
{
-
'web_widget' => Current.account.web_widgets,
-
'api' => Current.account.api_channels,
-
'email' => Current.account.email_channels,
-
'line' => Current.account.line_channels,
-
'telegram' => Current.account.telegram_channels,
-
'whatsapp' => Current.account.whatsapp_channels,
-
'sms' => Current.account.sms_channels
-
}[permitted_params[:channel][:type]]
-
end
-
-
1
def validate_limit
-
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
-
-
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
-
end
-
end
-
1
module Api::V1::Widget::MessagesHelper
-
end
-
1
module Api::V2::Accounts::HeatmapHelper
-
1
def generate_conversations_heatmap_report
-
timezone_data = generate_heatmap_data_for_timezone(params[:timezone_offset])
-
-
group_traffic_data(timezone_data)
-
end
-
-
1
private
-
-
1
def group_traffic_data(data)
-
# start with an empty array
-
result_arr = []
-
-
# pick all the unique dates from the data in ascending order
-
dates = data.pluck(:date).uniq.sort
-
-
# add the dates as the first row, leave an empty cell for the hour column
-
# e.g. ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03']
-
result_arr << (['Start of the hour'] + dates)
-
-
# group the data by hour, we do not need to sort it, because the data is already sorted
-
# given it starts from the beginning of the day
-
# here each hour is a key, and the value is an array of all the items for that hour at each date
-
# e.g. hour = 1
-
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
-
data.group_by { |d| d[:hour] }.each do |hour, items|
-
# create a new row for each hour
-
row = [format('%02d:00', hour)]
-
-
# group the items by date, so we can easily access the value for each date
-
# grouped values will be a hasg with the date as the key, and the value as the value
-
# e.g. { '2023-01-01' => [{date: 2023-01-01, value: 1}], '2023-01-02' => [{date: 2023-01-02, value: 1}], ... }
-
grouped_values = items.group_by { |d| d[:date] }
-
-
# now for each unique date we have, we can access the value for that date and append it to the array
-
dates.each do |date|
-
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
-
end
-
-
# row will look like ['22:00', 0, 0, 1, 4, 6, 7, 4]
-
# add the row to the result array
-
-
result_arr << row
-
end
-
-
# return the resultant array
-
# the result looks like this
-
# [
-
# ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03'],
-
# ['00:00', 0, 0, 0],
-
# ['01:00', 0, 0, 0],
-
# ['02:00', 0, 0, 0],
-
# ['03:00', 0, 0, 0],
-
# ['04:00', 0, 0, 0],
-
# ]
-
result_arr
-
end
-
-
1
def generate_heatmap_data_for_timezone(offset)
-
timezone = ActiveSupport::TimeZone[offset]&.name
-
timezone_today = DateTime.now.in_time_zone(timezone).beginning_of_day
-
-
timezone_data_raw = generate_heatmap_data(timezone_today, offset)
-
-
transform_data(timezone_data_raw, false)
-
end
-
-
1
def generate_heatmap_data(date, offset)
-
report_params = {
-
type: :account,
-
group_by: 'hour',
-
metric: 'conversations_count',
-
business_hours: false
-
}
-
-
V2::ReportBuilder.new(Current.account, report_params.merge({
-
since: since_timestamp(date),
-
until: until_timestamp(date),
-
timezone_offset: offset
-
})).build
-
end
-
-
1
def transform_data(data, zone_transform)
-
# rubocop:disable Rails/TimeZone
-
data.map do |d|
-
date = zone_transform ? Time.zone.at(d[:timestamp]) : Time.at(d[:timestamp])
-
{
-
date: date.to_date.to_s,
-
hour: date.hour,
-
value: d[:value]
-
}
-
end
-
# rubocop:enable Rails/TimeZone
-
end
-
-
1
def since_timestamp(date)
-
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
-
(date - number_of_days).to_i.to_s
-
end
-
-
1
def until_timestamp(date)
-
date.to_i.to_s
-
end
-
end
-
1
module Api::V2::Accounts::ReportsHelper
-
1
def generate_agents_report
-
reports = V2::Reports::AgentSummaryBuilder.new(
-
account: Current.account,
-
params: build_params(type: :agent)
-
).build
-
-
Current.account.users.map do |agent|
-
report = reports.find { |r| r[:id] == agent.id }
-
[agent.name] + generate_readable_report_metrics(report)
-
end
-
end
-
-
1
def generate_inboxes_report
-
reports = V2::Reports::InboxSummaryBuilder.new(
-
account: Current.account,
-
params: build_params(type: :inbox)
-
).build
-
-
Current.account.inboxes.map do |inbox|
-
report = reports.find { |r| r[:id] == inbox.id }
-
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report)
-
end
-
end
-
-
1
def generate_teams_report
-
reports = V2::Reports::TeamSummaryBuilder.new(
-
account: Current.account,
-
params: build_params(type: :team)
-
).build
-
-
Current.account.teams.map do |team|
-
report = reports.find { |r| r[:id] == team.id }
-
[team.name] + generate_readable_report_metrics(report)
-
end
-
end
-
-
1
def generate_labels_report
-
Current.account.labels.map do |label|
-
label_report = report_builder({ type: :label, id: label.id }).short_summary
-
[label.title] + generate_readable_report_metrics(label_report)
-
end
-
end
-
-
1
private
-
-
1
def build_params(base_params)
-
base_params.merge(
-
{
-
since: params[:since],
-
until: params[:until],
-
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
-
}
-
)
-
end
-
-
1
def report_builder(report_params)
-
V2::ReportBuilder.new(Current.account, build_params(report_params))
-
end
-
-
1
def generate_readable_report_metrics(report)
-
[
-
report[:conversations_count],
-
Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format,
-
Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format,
-
Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format,
-
report[:resolved_conversations_count]
-
]
-
end
-
end
-
1
module ApplicationHelper
-
1
def available_locales_with_name
-
LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) }
-
end
-
-
1
def feature_help_urls
-
features = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
-
features.each_with_object({}) do |feature, hash|
-
hash[feature['name']] = feature['help_url'] if feature['help_url']
-
end
-
end
-
end
-
1
module BillingHelper
-
1
private
-
-
1
def default_plan?(account)
-
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
-
default_plan = installation_config&.value&.first
-
-
# Return false if not plans are configured, so that no checks are enforced
-
return false if default_plan.blank?
-
-
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
-
end
-
-
1
def conversations_this_month(account)
-
account.conversations.where('created_at > ?', 30.days.ago).count
-
end
-
-
1
def non_web_inboxes(account)
-
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
-
end
-
-
1
def agents(account)
-
account.users.count
-
end
-
end
-
1
module CacheKeysHelper
-
1
def get_prefixed_cache_key(account_id, key)
-
36
"idb-cache-key-account-#{account_id}-#{key}"
-
end
-
-
1
def fetch_value_for_key(account_id, key)
-
27
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
-
27
value_from_cache = Redis::Alfred.get(prefixed_cache_key)
-
-
27
return value_from_cache if value_from_cache.present?
-
-
# zero epoch time: 1970-01-01 00:00:00 UTC
-
18
'0000000000'
-
end
-
end
-
1
module ContactHelper
-
1
def parse_name(full_name)
-
# If the input is nil or not a string, return a hash with all values set to nil
-
return default_name_hash if invalid_name?(full_name)
-
-
# If the input is a number, return a hash with the number as the first name
-
return numeric_name_hash(full_name) if valid_number?(full_name)
-
-
full_name = full_name.squish
-
-
# If full name consists of only one word, consider it as the first name
-
return single_word_name_hash(full_name) if single_word?(full_name)
-
-
parts = split_name(full_name)
-
parts = handle_conjunction(parts)
-
build_name_hash(parts)
-
end
-
-
1
private
-
-
1
def default_name_hash
-
{ first_name: nil, last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
-
end
-
-
1
def invalid_name?(full_name)
-
!full_name.is_a?(String) || full_name.empty?
-
end
-
-
1
def numeric_name_hash(full_name)
-
{ first_name: full_name, last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
-
end
-
-
1
def valid_number?(full_name)
-
full_name.gsub(/\s+/, '').match?(/\A\+?\d+\z/)
-
end
-
-
1
def single_word_name_hash(full_name)
-
{ first_name: full_name, last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
-
end
-
-
1
def single_word?(full_name)
-
full_name.split.size == 1
-
end
-
-
1
def split_name(full_name)
-
full_name.split
-
end
-
-
1
def handle_conjunction(parts)
-
conjunctions = ['and', '&']
-
parts.each_index do |i|
-
next unless conjunctions.include?(parts[i]) && i.positive?
-
-
parts[i - 1] = [parts[i - 1], parts[i + 1]].join(' ')
-
parts.delete_at(i)
-
parts.delete_at(i)
-
end
-
parts
-
end
-
-
1
def build_name_hash(parts)
-
suffix = parts.pop if parts.last.match?(/(\w+\.|[IVXLM]+|[A-Z]+)$/)
-
last_name = parts.pop
-
prefix = parts.shift if parts.first.match?(/^\w+\./)
-
first_name = parts.shift
-
middle_name = parts.join(' ')
-
-
hash = {
-
first_name: first_name,
-
last_name: last_name,
-
prefix: prefix,
-
middle_name: middle_name,
-
suffix: suffix
-
}
-
-
# Reverse name if "," was used in Last, First notation.
-
if hash[:first_name] =~ /,$/
-
hash[:first_name] = hash[:last_name]
-
hash[:last_name] = Regexp.last_match.pre_match
-
end
-
hash
-
end
-
end
-
##############################################
-
# Helpers to implement date range filtering to APIs
-
# Include in your controller or service class where params is available
-
##############################################
-
-
1
module DateRangeHelper
-
1
def range
-
return if params[:since].blank? || params[:until].blank?
-
-
parse_date_time(params[:since])...parse_date_time(params[:until])
-
end
-
-
1
def parse_date_time(datetime)
-
return datetime if datetime.is_a?(DateTime)
-
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
-
-
DateTime.strptime(datetime, '%s')
-
end
-
end
-
1
module EmailHelper
-
1
def extract_domain_without_tld(email)
-
domain = email.split('@').last
-
domain.split('.').first
-
end
-
-
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
-
# This is not a mandatory requirement for email addresses, but it is a common practice.
-
# john+test@xyc.com is the same as john@xyc.com
-
1
def normalize_email_with_plus_addressing(email)
-
"#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase
-
end
-
-
1
def parse_email_variables(conversation, email)
-
case email
-
when modified_liquid_content(email)
-
template = Liquid::Template.parse(modified_liquid_content(email))
-
template.render(message_drops(conversation))
-
when URI::MailTo::EMAIL_REGEXP
-
email
-
end
-
end
-
-
1
def modified_liquid_content(email)
-
# This regex is used to match the code blocks in the content
-
# We don't want to process liquid in code blocks
-
email.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
-
end
-
-
1
def message_drops(conversation)
-
{
-
'contact' => ContactDrop.new(conversation.contact)
-
}
-
end
-
end
-
1
module FileTypeHelper
-
# NOTE: video, audio, image, etc are filetypes previewable in frontend
-
1
def file_type(content_type)
-
return :image if image_file?(content_type)
-
return :video if video_file?(content_type)
-
return :audio if content_type&.include?('audio/')
-
-
:file
-
end
-
-
# Used in case of DIRECT_UPLOADS_ENABLED=true
-
1
def file_type_by_signed_id(signed_id)
-
blob = ActiveStorage::Blob.find_signed(signed_id)
-
file_type(blob&.content_type)
-
end
-
-
1
def image_file?(content_type)
-
[
-
'image/jpeg',
-
'image/png',
-
'image/gif',
-
'image/bmp',
-
'image/webp',
-
'image'
-
].include?(content_type)
-
end
-
-
1
def video_file?(content_type)
-
[
-
'video/ogg',
-
'video/mp4',
-
'video/webm',
-
'video/quicktime',
-
'video'
-
].include?(content_type)
-
end
-
end
-
1
module Filters::FilterHelper
-
1
def build_condition_query(model_filters, query_hash, current_index)
-
current_filter = model_filters[query_hash['attribute_key']]
-
-
# Throw InvalidOperator Error if the attribute is a standard attribute
-
# and the operator is not allowed in the config
-
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
-
raise CustomExceptions::CustomFilter::InvalidOperator.new(
-
attribute_name: query_hash['attribute_key'],
-
allowed_keys: current_filter['filter_operators']
-
)
-
end
-
-
# Every other filter expects a value to be present
-
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
-
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
-
end
-
-
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
-
# The query becomes empty only when it doesn't match to any supported
-
# standard attribute or custom attribute defined in the account.
-
if condition_query.empty?
-
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
-
allowed_keys: model_filters.keys)
-
end
-
-
condition_query
-
end
-
-
1
def build_condition_query_string(current_filter, query_hash, current_index)
-
filter_operator_value = filter_operation(query_hash, current_index)
-
-
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
-
-
case current_filter['attribute_type']
-
when 'additional_attributes'
-
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
-
else
-
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
-
end
-
end
-
-
1
def handle_nil_filter(query_hash, current_index)
-
attribute_type = "#{filter_config[:entity].downcase}_attribute"
-
custom_attribute_query(query_hash, attribute_type, current_index)
-
end
-
-
1
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
-
if data_type == 'text_case_insensitive'
-
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
-
"#{filter_operator_value} #{query_hash[:query_operator]}"
-
else
-
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
-
"#{filter_operator_value} #{query_hash[:query_operator]} "
-
end
-
end
-
-
1
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
-
case current_filter['data_type']
-
when 'date'
-
date_filter(current_filter, query_hash, filter_operator_value)
-
when 'labels'
-
tag_filter_query(query_hash, current_index)
-
when 'text_case_insensitive'
-
text_case_insensitive_filter(query_hash, filter_operator_value)
-
else
-
default_filter(query_hash, filter_operator_value)
-
end
-
end
-
-
1
def date_filter(current_filter, query_hash, filter_operator_value)
-
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
-
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
-
end
-
-
1
def text_case_insensitive_filter(query_hash, filter_operator_value)
-
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
-
"#{filter_operator_value} #{query_hash[:query_operator]}"
-
end
-
-
1
def default_filter(query_hash, filter_operator_value)
-
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
-
end
-
-
1
def validate_single_condition(condition)
-
return if condition['query_operator'].nil?
-
return if condition['query_operator'].empty?
-
-
operator = condition['query_operator'].upcase
-
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
-
end
-
-
1
def conversation_status_values(values)
-
return Conversation.statuses.values if values.include?('all')
-
-
values.map { |x| Conversation.statuses[x.to_sym] }
-
end
-
-
1
def conversation_priority_values(values)
-
values.map { |x| Conversation.priorities[x.to_sym] }
-
end
-
-
1
def message_type_values(values)
-
values.map { |x| Message.message_types[x.to_sym] }
-
end
-
end
-
1
module FrontendUrlsHelper
-
1
def frontend_url(path, **query_params)
-
url_params = query_params.blank? ? '' : "?#{query_params.to_query}"
-
"#{root_url}app/#{path}#{url_params}"
-
end
-
end
-
1
module Instagram::IntegrationHelper
-
1
REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze
-
-
# Generates a signed JWT token for Instagram integration
-
#
-
# @param account_id [Integer] The account ID to encode in the token
-
# @return [String, nil] The encoded JWT token or nil if client secret is missing
-
1
def generate_instagram_token(account_id)
-
return if client_secret.blank?
-
-
JWT.encode(token_payload(account_id), client_secret, 'HS256')
-
rescue StandardError => e
-
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
-
nil
-
end
-
-
1
def token_payload(account_id)
-
{
-
sub: account_id,
-
iat: Time.current.to_i
-
}
-
end
-
-
# Verifies and decodes a Instagram JWT token
-
#
-
# @param token [String] The JWT token to verify
-
# @return [Integer, nil] The account ID from the token or nil if invalid
-
1
def verify_instagram_token(token)
-
return if token.blank? || client_secret.blank?
-
-
decode_token(token, client_secret)
-
end
-
-
1
private
-
-
1
def client_secret
-
@client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
-
end
-
-
1
def decode_token(token, secret)
-
JWT.decode(token, secret, true, {
-
algorithm: 'HS256',
-
verify_expiration: true
-
}).first['sub']
-
rescue StandardError => e
-
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
-
nil
-
end
-
end
-
1
module Linear::IntegrationHelper
-
# Generates a signed JWT token for Linear integration
-
#
-
# @param account_id [Integer] The account ID to encode in the token
-
# @return [String, nil] The encoded JWT token or nil if client secret is missing
-
1
def generate_linear_token(account_id)
-
return if client_secret.blank?
-
-
JWT.encode(token_payload(account_id), client_secret, 'HS256')
-
rescue StandardError => e
-
Rails.logger.error("Failed to generate Linear token: #{e.message}")
-
nil
-
end
-
-
1
def token_payload(account_id)
-
{
-
sub: account_id,
-
iat: Time.current.to_i
-
}
-
end
-
-
# Verifies and decodes a Linear JWT token
-
#
-
# @param token [String] The JWT token to verify
-
# @return [Integer, nil] The account ID from the token or nil if invalid
-
1
def verify_linear_token(token)
-
return if token.blank? || client_secret.blank?
-
-
decode_token(token, client_secret)
-
end
-
-
1
private
-
-
1
def client_secret
-
@client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
-
end
-
-
1
def decode_token(token, secret)
-
JWT.decode(token, secret, true, {
-
algorithm: 'HS256',
-
verify_expiration: true
-
}).first['sub']
-
rescue StandardError => e
-
Rails.logger.error("Unexpected error verifying Linear token: #{e.message}")
-
nil
-
end
-
end
-
1
module MessageFormatHelper
-
1
include RegexHelper
-
-
1
def transform_user_mention_content(message_content)
-
# attachment message without content, message_content is nil
-
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
-
end
-
-
1
def render_message_content(message_content)
-
ChatwootMarkdownRenderer.new(message_content).render_message
-
end
-
end
-
1
module PortalHelper
-
1
def generate_portal_bg_color(portal_color, theme)
-
base_color = theme == 'dark' ? 'black' : 'white'
-
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
-
end
-
-
1
def generate_portal_bg(portal_color, theme)
-
generate_portal_bg_color(portal_color, theme)
-
end
-
-
1
def generate_gradient_to_bottom(theme)
-
base_color = theme == 'dark' ? '#151718' : 'white'
-
"linear-gradient(to bottom, transparent, #{base_color})"
-
end
-
-
1
def generate_portal_hover_color(portal_color, theme)
-
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
-
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
-
end
-
-
1
def language_name(locale)
-
language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
-
language_map[locale] || locale
-
end
-
-
1
def theme_query_string(theme)
-
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
-
end
-
-
1
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
-
if is_plain_layout_enabled
-
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
-
else
-
"/hc/#{portal_slug}/#{portal_locale}"
-
end
-
end
-
-
1
def generate_category_link(params)
-
portal_slug = params[:portal_slug]
-
category_locale = params[:category_locale]
-
category_slug = params[:category_slug]
-
theme = params[:theme]
-
is_plain_layout_enabled = params[:is_plain_layout_enabled]
-
-
if is_plain_layout_enabled
-
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
-
else
-
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
-
end
-
end
-
-
1
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
-
if is_plain_layout_enabled
-
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
-
else
-
"/hc/#{portal_slug}/articles/#{article_slug}"
-
end
-
end
-
-
1
def render_category_content(content)
-
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
-
end
-
-
1
def thumbnail_bg_color(username)
-
colors = ['#6D95BA', '#A4C3C3', '#E19191']
-
return colors.sample if username.blank?
-
-
colors[username.length % colors.size]
-
end
-
end
-
1
module ReportHelper
-
1
private
-
-
1
def scope
-
case params[:type]
-
when :account
-
account
-
when :inbox
-
inbox
-
when :agent
-
user
-
when :label
-
label
-
when :team
-
team
-
end
-
end
-
-
1
def conversations_count
-
(get_grouped_values conversations).count
-
end
-
-
1
def incoming_messages_count
-
(get_grouped_values incoming_messages).count
-
end
-
-
1
def outgoing_messages_count
-
(get_grouped_values outgoing_messages).count
-
end
-
-
1
def resolutions_count
-
(get_grouped_values resolutions).count
-
end
-
-
1
def bot_resolutions_count
-
(get_grouped_values bot_resolutions).count
-
end
-
-
1
def bot_handoffs_count
-
(get_grouped_values bot_handoffs).count
-
end
-
-
1
def conversations
-
scope.conversations.where(account_id: account.id, created_at: range)
-
end
-
-
1
def incoming_messages
-
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
-
end
-
-
1
def outgoing_messages
-
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
-
end
-
-
1
def resolutions
-
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
-
conversations: { status: :resolved }, created_at: range).distinct
-
end
-
-
1
def bot_resolutions
-
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
-
conversations: { status: :resolved }, created_at: range).distinct
-
end
-
-
1
def bot_handoffs
-
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
-
created_at: range).distinct
-
end
-
-
1
def avg_first_response_time
-
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
-
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
-
-
grouped_reporting_events.average(:value)
-
end
-
-
1
def reply_time
-
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'reply_time', account_id: account.id))
-
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
-
-
grouped_reporting_events.average(:value)
-
end
-
-
1
def avg_resolution_time
-
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id))
-
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
-
-
grouped_reporting_events.average(:value)
-
end
-
-
1
def avg_resolution_time_summary
-
reporting_events = scope.reporting_events
-
.where(name: 'conversation_resolved', account_id: account.id, created_at: range)
-
avg_rt = if params[:business_hours].present?
-
reporting_events.average(:value_in_business_hours)
-
else
-
reporting_events.average(:value)
-
end
-
-
return 0 if avg_rt.blank?
-
-
avg_rt
-
end
-
-
1
def reply_time_summary
-
reporting_events = scope.reporting_events
-
.where(name: 'reply_time', account_id: account.id, created_at: range)
-
reply_time = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
-
-
return 0 if reply_time.blank?
-
-
reply_time
-
end
-
-
1
def avg_first_response_time_summary
-
reporting_events = scope.reporting_events
-
.where(name: 'first_response', account_id: account.id, created_at: range)
-
avg_frt = if params[:business_hours].present?
-
reporting_events.average(:value_in_business_hours)
-
else
-
reporting_events.average(:value)
-
end
-
-
return 0 if avg_frt.blank?
-
-
avg_frt
-
end
-
end
-
1
module ReportingEventHelper
-
1
def business_hours(inbox, from, to)
-
return 0 unless inbox.working_hours_enabled?
-
-
inbox_working_hours = configure_working_hours(inbox.working_hours)
-
return 0 if inbox_working_hours.blank?
-
-
# Configure working hours
-
WorkingHours::Config.working_hours = inbox_working_hours
-
-
# Configure timezone
-
WorkingHours::Config.time_zone = inbox.timezone
-
-
# Use inbox timezone to change from & to values.
-
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
-
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
-
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
-
end
-
-
1
def last_non_human_activity(conversation)
-
# check if a handoff event already exists
-
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
-
-
# if a handoff exists, last non human activity is when the handoff ended,
-
# otherwise it's when the conversation was created
-
handoff_event&.event_end_time || conversation.created_at
-
end
-
-
1
private
-
-
1
def configure_working_hours(working_hours)
-
working_hours.each_with_object({}) do |working_hour, object|
-
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
-
end
-
end
-
-
1
def day(day_of_week)
-
week_days = {
-
0 => :sun,
-
1 => :mon,
-
2 => :tue,
-
3 => :wed,
-
4 => :thu,
-
5 => :fri,
-
6 => :sat
-
}
-
week_days[day_of_week]
-
end
-
-
1
def working_hour_range(working_hour)
-
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
-
end
-
-
1
def format_time(hour, minute)
-
hour = hour < 10 ? "0#{hour}" : hour
-
minute = minute < 10 ? "0#{minute}" : minute
-
"#{hour}:#{minute}"
-
end
-
end
-
1
module Shopify::IntegrationHelper
-
1
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
-
-
# Generates a signed JWT token for Shopify integration
-
#
-
# @param account_id [Integer] The account ID to encode in the token
-
# @return [String, nil] The encoded JWT token or nil if client secret is missing
-
1
def generate_shopify_token(account_id)
-
return if client_secret.blank?
-
-
JWT.encode(token_payload(account_id), client_secret, 'HS256')
-
rescue StandardError => e
-
Rails.logger.error("Failed to generate Shopify token: #{e.message}")
-
nil
-
end
-
-
1
def token_payload(account_id)
-
{
-
sub: account_id,
-
iat: Time.current.to_i
-
}
-
end
-
-
# Verifies and decodes a Shopify JWT token
-
#
-
# @param token [String] The JWT token to verify
-
# @return [Integer, nil] The account ID from the token or nil if invalid
-
1
def verify_shopify_token(token)
-
return if token.blank? || client_secret.blank?
-
-
decode_token(token, client_secret)
-
end
-
-
1
private
-
-
1
def client_id
-
@client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil)
-
end
-
-
1
def client_secret
-
@client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
-
end
-
-
1
def decode_token(token, secret)
-
JWT.decode(
-
token,
-
secret,
-
true,
-
{
-
algorithm: 'HS256',
-
verify_expiration: true
-
}
-
).first['sub']
-
rescue StandardError => e
-
Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}")
-
nil
-
end
-
end
-
1
module SuperAdmin::AccountFeaturesHelper
-
1
def self.account_features
-
YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
-
end
-
-
1
def self.account_premium_features
-
account_features.filter { |feature| feature['premium'] }.pluck('name')
-
end
-
-
# Returns a hash mapping feature names to their display names
-
1
def self.feature_display_names
-
account_features.each_with_object({}) do |feature, hash|
-
hash[feature['name']] = feature['display_name']
-
end
-
end
-
-
1
def self.filter_internal_features(features)
-
return features if ChatwootApp.chatwoot_cloud?
-
-
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
-
features.except(*internal_features)
-
end
-
-
1
def self.filter_deprecated_features(features)
-
deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name')
-
features.except(*deprecated_features)
-
end
-
-
1
def self.sort_and_transform_features(features, display_names)
-
features.sort_by { |key, _| display_names[key] || key }
-
.to_h
-
.transform_keys { |key| [key, display_names[key]] }
-
end
-
-
1
def self.partition_features(features)
-
filtered = filter_internal_features(features)
-
filtered = filter_deprecated_features(filtered)
-
display_names = feature_display_names
-
-
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
-
-
[
-
sort_and_transform_features(regular, display_names),
-
sort_and_transform_features(premium, display_names)
-
]
-
end
-
-
1
def self.filtered_features(features)
-
regular, premium = partition_features(features)
-
regular.merge(premium)
-
end
-
end
-
1
module TimezoneHelper
-
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
-
# would return the timezone without considering day light savings. To get the correct timezone,
-
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
-
#
-
# https://github.com/rails/rails/pull/22243
-
# https://github.com/rails/rails/issues/21501
-
# https://github.com/rails/rails/issues/7297
-
1
def timezone_name_from_offset(offset)
-
return 'UTC' if offset.blank?
-
-
offset_in_seconds = offset.to_f * 3600
-
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
-
zone.now.utc_offset == offset_in_seconds
-
end
-
-
return matching_zone.name if matching_zone
-
end
-
end
-
1
module WidgetHelper
-
1
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
-
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
-
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
-
token = ::Widget::TokenService.new(payload: payload).generate_token
-
-
[contact_inbox, token]
-
end
-
end
-
class Account::ContactsExportJob < ApplicationJob
-
queue_as :low
-
-
def perform(account_id, user_id, column_names, params)
-
@account = Account.find(account_id)
-
@params = params
-
@account_user = @account.users.find(user_id)
-
-
headers = valid_headers(column_names)
-
generate_csv(headers)
-
send_mail
-
end
-
-
private
-
-
def generate_csv(headers)
-
csv_data = CSV.generate do |csv|
-
csv << headers
-
contacts.each do |contact|
-
csv << headers.map { |header| contact.send(header) }
-
end
-
end
-
-
attach_export_file(csv_data)
-
end
-
-
def contacts
-
if @params.present? && @params[:payload].present? && @params[:payload].any?
-
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
-
result[:contacts]
-
elsif @params[:label].present?
-
@account.contacts.resolved_contacts.tagged_with(@params[:label], any: true)
-
else
-
@account.contacts.resolved_contacts
-
end
-
end
-
-
def valid_headers(column_names)
-
(column_names.presence || default_columns) & Contact.column_names
-
end
-
-
def attach_export_file(csv_data)
-
return if csv_data.blank?
-
-
@account.contacts_export.attach(
-
io: StringIO.new(csv_data),
-
filename: "#{@account.name}_#{@account.id}_contacts.csv",
-
content_type: 'text/csv'
-
)
-
end
-
-
def send_mail
-
file_url = account_contact_export_url
-
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: @account)
-
mailer.contact_export_complete(file_url, @account_user.email)&.deliver_later
-
end
-
-
def account_contact_export_url
-
Rails.application.routes.url_helpers.rails_blob_url(@account.contacts_export)
-
end
-
-
def default_columns
-
%w[id name email phone_number]
-
end
-
end
-
class Account::ConversationsResolutionSchedulerJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
Account.with_auto_resolve.find_each(batch_size: 100) do |account|
-
Conversations::ResolutionJob.perform_later(account: account)
-
end
-
end
-
end
-
Account::ConversationsResolutionSchedulerJob.prepend_mod_with('Account::ConversationsResolutionSchedulerJob')
-
1
class ActionCableBroadcastJob < ApplicationJob
-
1
queue_as :critical
-
1
include Events::Types
-
-
CONVERSATION_UPDATE_EVENTS = [
-
1
CONVERSATION_READ,
-
CONVERSATION_UPDATED,
-
TEAM_CHANGED,
-
ASSIGNEE_CHANGED,
-
CONVERSATION_STATUS_CHANGED
-
].freeze
-
-
1
def perform(members, event_name, data)
-
return if members.blank?
-
-
broadcast_data = prepare_broadcast_data(event_name, data)
-
broadcast_to_members(members, event_name, broadcast_data)
-
end
-
-
1
private
-
-
# Ensures that only the latest available data is sent to prevent UI issues
-
# caused by out-of-order events during high-traffic periods. This prevents
-
# the conversation job from processing outdated data.
-
1
def prepare_broadcast_data(event_name, data)
-
return data unless CONVERSATION_UPDATE_EVENTS.include?(event_name)
-
-
account = Account.find(data[:account_id])
-
conversation = account.conversations.find_by!(display_id: data[:id])
-
conversation.push_event_data.merge(account_id: data[:account_id])
-
end
-
-
1
def broadcast_to_members(members, event_name, broadcast_data)
-
members.each do |member|
-
ActionCable.server.broadcast(
-
member,
-
{
-
event: event_name,
-
data: broadcast_data
-
}
-
)
-
end
-
end
-
end
-
class AgentBots::WebhookJob < WebhookJob
-
queue_as :high
-
end
-
class Agents::DestroyJob < ApplicationJob
-
queue_as :low
-
-
def perform(account, user)
-
ActiveRecord::Base.transaction do
-
destroy_notification_setting(account, user)
-
remove_user_from_teams(account, user)
-
remove_user_from_inboxes(account, user)
-
unassign_conversations(account, user)
-
end
-
end
-
-
private
-
-
def remove_user_from_inboxes(account, user)
-
inboxes = account.inboxes.all
-
inbox_members = user.inbox_members.where(inbox_id: inboxes.pluck(:id))
-
inbox_members.destroy_all
-
end
-
-
def remove_user_from_teams(account, user)
-
teams = account.teams.all
-
team_members = user.team_members.where(team_id: teams.pluck(:id))
-
team_members.destroy_all
-
end
-
-
def destroy_notification_setting(account, user)
-
setting = user.notification_settings.find_by(account_id: account.id)
-
setting&.destroy!
-
end
-
-
def unassign_conversations(account, user)
-
# rubocop:disable Rails/SkipsModelValidations
-
user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
end
-
1
class ApplicationJob < ActiveJob::Base
-
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
-
1
discard_on ActiveJob::DeserializationError do |job, error|
-
Rails.logger.info("Skipping #{job.class} with #{
-
job.instance_variable_get(:@serialized_arguments)
-
} because of ActiveJob::DeserializationError (#{error.message})")
-
end
-
end
-
1
class Avatar::AvatarFromGravatarJob < ApplicationJob
-
1
queue_as :low
-
-
1
def perform(avatarable, email)
-
return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present?
-
return if email.blank?
-
return if avatarable.avatar_url.present?
-
-
hash = Digest::MD5.hexdigest(email)
-
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
-
Avatar::AvatarFromUrlJob.perform_later(avatarable, gravatar_url)
-
end
-
end
-
class Avatar::AvatarFromUrlJob < ApplicationJob
-
queue_as :low
-
-
def perform(avatarable, avatar_url)
-
return unless avatarable.respond_to?(:avatar)
-
-
avatar_file = Down.download(
-
avatar_url,
-
max_size: 15 * 1024 * 1024
-
)
-
if valid_image?(avatar_file)
-
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename,
-
content_type: avatar_file.content_type)
-
end
-
rescue Down::NotFound, Down::Error => e
-
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
-
end
-
-
private
-
-
def valid_image?(file)
-
return false if file.original_filename.blank?
-
-
# TODO: check if the file is an actual image
-
-
true
-
end
-
end
-
class BulkActionsJob < ApplicationJob
-
include DateRangeHelper
-
-
queue_as :medium
-
attr_accessor :records
-
-
MODEL_TYPE = ['Conversation'].freeze
-
-
def perform(account:, params:, user:)
-
@account = account
-
Current.user = user
-
@params = params
-
@records = records_to_updated(params[:ids])
-
bulk_update
-
ensure
-
Current.reset
-
end
-
-
def bulk_update
-
bulk_remove_labels
-
bulk_conversation_update
-
end
-
-
def bulk_conversation_update
-
params = available_params(@params)
-
records.each do |conversation|
-
bulk_add_labels(conversation)
-
bulk_snoozed_until(conversation)
-
conversation.update(params) if params
-
end
-
end
-
-
def bulk_remove_labels
-
records.each do |conversation|
-
remove_labels(conversation)
-
end
-
end
-
-
def available_params(params)
-
return unless params[:fields]
-
-
params[:fields].delete_if { |key, value| value.nil? && key == 'status' }
-
end
-
-
def bulk_add_labels(conversation)
-
conversation.add_labels(@params[:labels][:add]) if @params[:labels] && @params[:labels][:add]
-
end
-
-
def bulk_snoozed_until(conversation)
-
conversation.snoozed_until = parse_date_time(@params[:snoozed_until].to_s) if @params[:snoozed_until]
-
end
-
-
def remove_labels(conversation)
-
return unless @params[:labels] && @params[:labels][:remove]
-
-
labels = conversation.label_list - @params[:labels][:remove]
-
conversation.update(label_list: labels)
-
end
-
-
def records_to_updated(ids)
-
current_model = @params[:type].camelcase
-
return unless MODEL_TYPE.include?(current_model)
-
-
current_model.constantize&.where(account_id: @account.id, display_id: ids)
-
end
-
end
-
class Campaigns::TriggerOneoffCampaignJob < ApplicationJob
-
queue_as :low
-
-
def perform(campaign)
-
campaign.trigger!
-
end
-
end
-
class Channels::Whatsapp::TemplatesSyncJob < ApplicationJob
-
queue_as :low
-
-
def perform(whatsapp_channel)
-
whatsapp_channel.sync_templates
-
end
-
end
-
class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
-
queue_as :low
-
-
def perform
-
Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
-
.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago)
-
.limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
-
.each do |channel|
-
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
-
end
-
end
-
end
-
class ContactIpLookupJob < ApplicationJob
-
queue_as :default
-
-
def perform(contact)
-
update_contact_location_from_ip(contact)
-
rescue Errno::ETIMEDOUT => e
-
Rails.logger.warn "Exception: ip resolution failed : #{e.message}"
-
end
-
-
private
-
-
def update_contact_location_from_ip(contact)
-
geocoder_result = IpLookupService.new.perform(get_contact_ip(contact))
-
return unless geocoder_result
-
-
contact.additional_attributes ||= {}
-
contact.additional_attributes['city'] = geocoder_result.city
-
contact.additional_attributes['country'] = geocoder_result.country
-
contact.additional_attributes['country_code'] = geocoder_result.country_code
-
contact.save!
-
end
-
-
def get_contact_ip(contact)
-
contact.additional_attributes&.dig('updated_at_ip') || contact.additional_attributes&.dig('created_at_ip')
-
end
-
end
-
class Conversations::ActivityMessageJob < ApplicationJob
-
queue_as :high
-
-
def perform(conversation, message_params)
-
conversation.messages.create!(message_params)
-
end
-
end
-
class Conversations::ReopenSnoozedConversationsJob < ApplicationJob
-
queue_as :low
-
-
def perform
-
Conversation.where(status: :snoozed).where(snoozed_until: 3.days.ago..Time.current).all.find_each(batch_size: 100, &:open!)
-
end
-
end
-
class Conversations::ResolutionJob < ApplicationJob
-
queue_as :low
-
-
def perform(account:)
-
# limiting the number of conversations to be resolved to avoid any performance issues
-
resolvable_conversations = account.conversations.resolvable(account.auto_resolve_after).limit(Limits::BULK_ACTIONS_LIMIT)
-
resolvable_conversations.each do |conversation|
-
# send message from bot that conversation has been resolved
-
# do this is account.auto_resolve_message is set
-
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
-
conversation.toggle_status
-
end
-
end
-
end
-
class Conversations::UpdateMessageStatusJob < ApplicationJob
-
queue_as :low
-
-
# This job only support marking messages as read or delivered, update this array if we want to support more statuses
-
VALID_STATUSES = %w[read delivered].freeze
-
-
def perform(conversation_id, timestamp, status = :read)
-
return unless VALID_STATUSES.include?(status.to_s)
-
-
conversation = Conversation.find_by(id: conversation_id)
-
-
return unless conversation
-
-
# Mark every message created before the user's viewing time read or delivered
-
conversation.messages.where(status: %w[sent delivered])
-
.where.not(message_type: 'incoming')
-
.where('messages.created_at <= ?', timestamp).find_each do |message|
-
Messages::StatusUpdateService.new(message, status).perform
-
end
-
end
-
end
-
class Conversations::UserMentionJob < ApplicationJob
-
queue_as :default
-
-
def perform(mentioned_user_ids, conversation_id, account_id)
-
mentioned_user_ids.each do |mentioned_user_id|
-
mention = Mention.find_by(
-
user_id: mentioned_user_id,
-
conversation_id: conversation_id,
-
account_id: account_id
-
)
-
-
if mention.nil?
-
Mention.create!(
-
user_id: mentioned_user_id,
-
conversation_id: conversation_id,
-
mentioned_at: Time.zone.now,
-
account_id: account_id
-
)
-
else
-
mention.update(mentioned_at: Time.zone.now)
-
end
-
end
-
end
-
end
-
class Crm::SetupJob < ApplicationJob
-
queue_as :default
-
-
def perform(hook_id)
-
hook = Integrations::Hook.find_by(id: hook_id)
-
-
return if hook.blank? || hook.disabled?
-
-
begin
-
setup_service = create_setup_service(hook)
-
return if setup_service.nil?
-
-
setup_service.setup
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
-
Rails.logger.error "Error in CRM setup for hook ##{hook_id} (#{hook.app_id}): #{e.message}"
-
end
-
end
-
-
private
-
-
def create_setup_service(hook)
-
case hook.app_id
-
when 'leadsquared'
-
Crm::Leadsquared::SetupService.new(hook)
-
# Add cases for future CRMs here
-
# when 'hubspot'
-
# Crm::Hubspot::SetupService.new(hook)
-
# when 'zoho'
-
# Crm::Zoho::SetupService.new(hook)
-
else
-
Rails.logger.error "Unsupported CRM app_id: #{hook.app_id}"
-
nil
-
end
-
end
-
end
-
# TODO: logic is written tailored to contact import since its the only import available
-
# let's break this logic and clean this up in future
-
-
class DataImportJob < ApplicationJob
-
queue_as :low
-
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
-
-
def perform(data_import)
-
@data_import = data_import
-
@contact_manager = DataImport::ContactManager.new(@data_import.account)
-
begin
-
process_import_file
-
send_import_notification_to_admin
-
rescue CSV::MalformedCSVError => e
-
handle_csv_error(e)
-
end
-
end
-
-
private
-
-
def process_import_file
-
@data_import.update!(status: :processing)
-
contacts, rejected_contacts = parse_csv_and_build_contacts
-
-
import_contacts(contacts)
-
update_data_import_status(contacts.length, rejected_contacts.length)
-
save_failed_records_csv(rejected_contacts)
-
end
-
-
def parse_csv_and_build_contacts
-
contacts = []
-
rejected_contacts = []
-
# Ensuring that importing non utf-8 characters will not throw error
-
data = @data_import.import_file.download
-
utf8_data = data.force_encoding('UTF-8')
-
-
# Ensure that the data is valid UTF-8, preserving valid characters
-
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
-
-
csv = CSV.parse(clean_data, headers: true)
-
-
csv.each do |row|
-
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
-
if current_contact.valid?
-
contacts << current_contact
-
else
-
append_rejected_contact(row, current_contact, rejected_contacts)
-
end
-
end
-
-
[contacts, rejected_contacts]
-
end
-
-
def append_rejected_contact(row, contact, rejected_contacts)
-
row['errors'] = contact.errors.full_messages.join(', ')
-
rejected_contacts << row
-
end
-
-
def import_contacts(contacts)
-
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
-
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
-
end
-
-
def update_data_import_status(processed_records, rejected_records)
-
@data_import.update!(status: :completed, processed_records: processed_records, total_records: processed_records + rejected_records)
-
end
-
-
def save_failed_records_csv(rejected_contacts)
-
csv_data = generate_csv_data(rejected_contacts)
-
return if csv_data.blank?
-
-
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
-
content_type: 'text/csv')
-
send_import_notification_to_admin
-
end
-
-
def generate_csv_data(rejected_contacts)
-
headers = CSV.parse(@data_import.import_file.download, headers: true).headers
-
headers << 'errors'
-
return if rejected_contacts.blank?
-
-
CSV.generate do |csv|
-
csv << headers
-
rejected_contacts.each do |record|
-
csv << record
-
end
-
end
-
end
-
-
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
-
@data_import.update!(status: :failed)
-
send_import_failed_notification_to_admin
-
end
-
-
def send_import_notification_to_admin
-
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
-
end
-
-
def send_import_failed_notification_to_admin
-
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
-
end
-
end
-
class DeleteObjectJob < ApplicationJob
-
queue_as :low
-
-
def perform(object, user = nil, ip = nil)
-
object.destroy!
-
process_post_deletion_tasks(object, user, ip)
-
end
-
-
def process_post_deletion_tasks(object, user, ip); end
-
end
-
-
DeleteObjectJob.prepend_mod_with('DeleteObjectJob')
-
1
class EventDispatcherJob < ApplicationJob
-
1
queue_as :critical
-
-
1
def perform(event_name, timestamp, data)
-
Rails.configuration.dispatcher.async_dispatcher.publish_event(event_name, timestamp, data)
-
end
-
end
-
class HookJob < MutexApplicationJob
-
retry_on LockAcquisitionError, wait: 3.seconds, attempts: 3
-
-
queue_as :medium
-
-
def perform(hook, event_name, event_data = {})
-
return if hook.disabled?
-
-
case hook.app_id
-
when 'slack'
-
process_slack_integration(hook, event_name, event_data)
-
when 'dialogflow'
-
process_dialogflow_integration(hook, event_name, event_data)
-
when 'google_translate'
-
google_translate_integration(hook, event_name, event_data)
-
when 'leadsquared'
-
process_leadsquared_integration_with_lock(hook, event_name, event_data)
-
end
-
rescue StandardError => e
-
Rails.logger.error e
-
end
-
-
private
-
-
def process_slack_integration(hook, event_name, event_data)
-
return unless ['message.created'].include?(event_name)
-
-
message = event_data[:message]
-
if message.attachments.blank?
-
::SendOnSlackJob.perform_later(message, hook)
-
else
-
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
-
end
-
end
-
-
def process_dialogflow_integration(hook, event_name, event_data)
-
return unless ['message.created', 'message.updated'].include?(event_name)
-
-
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
-
end
-
-
def google_translate_integration(hook, event_name, event_data)
-
return unless ['message.created'].include?(event_name)
-
-
message = event_data[:message]
-
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
-
end
-
-
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
-
# Why do we need a mutex here? glad you asked
-
# When a new conversation is created. We get a contact created event, immediately followed by
-
# a contact updated event, and then a conversation created event.
-
# This all happens within milliseconds of each other.
-
# Now each of these subsequent event handlers need to have a leadsquared lead created and the contact to have the ID.
-
# If the lead data is not present, we try to search the API and create a new lead if it doesn't exist.
-
# This gives us a bad race condition that allows the API to create multiple leads for the same contact.
-
#
-
# This would have not been a problem if the email and phone number were unique identifiers for contacts at LeadSquared
-
# But then this is configurable in the LeadSquared settings, and may or may not be unique.
-
valid_event_names = ['contact.updated', 'conversation.created', 'conversation.resolved']
-
return unless valid_event_names.include?(event_name)
-
return unless hook.feature_allowed?
-
-
key = format(::Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: hook.id)
-
with_lock(key) do
-
process_leadsquared_integration(hook, event_name, event_data)
-
end
-
end
-
-
def process_leadsquared_integration(hook, event_name, event_data)
-
# Process the event with the processor service
-
processor = Crm::Leadsquared::ProcessorService.new(hook)
-
-
case event_name
-
when 'contact.updated'
-
processor.handle_contact(event_data[:contact])
-
when 'conversation.created'
-
processor.handle_conversation_created(event_data[:conversation])
-
when 'conversation.resolved'
-
processor.handle_conversation_resolved(event_data[:conversation])
-
end
-
end
-
end
-
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
-
email_inboxes.find_each(batch_size: 100) do |inbox|
-
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if should_fetch_emails?(inbox)
-
end
-
end
-
-
private
-
-
def should_fetch_emails?(inbox)
-
inbox.channel.imap_enabled && !inbox.account.suspended?
-
end
-
end
-
require 'net/imap'
-
-
class Inboxes::FetchImapEmailsJob < MutexApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform(channel, interval = 1)
-
return unless should_fetch_email?(channel)
-
-
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
-
-
with_lock(key, 5.minutes) do
-
process_email_for_channel(channel, interval)
-
end
-
rescue *ExceptionList::IMAP_EXCEPTIONS => e
-
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
-
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
-
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
-
rescue LockAcquisitionError
-
Rails.logger.error "Lock failed for #{channel.inbox.id}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
-
end
-
-
private
-
-
def should_fetch_email?(channel)
-
channel.imap_enabled? && !channel.reauthorization_required?
-
end
-
-
def process_email_for_channel(channel, interval)
-
inbound_emails = if channel.microsoft?
-
Imap::MicrosoftFetchEmailService.new(channel: channel, interval: interval).perform
-
elsif channel.google?
-
Imap::GoogleFetchEmailService.new(channel: channel, interval: interval).perform
-
else
-
Imap::FetchEmailService.new(channel: channel, interval: interval).perform
-
end
-
inbound_emails.map do |inbound_mail|
-
process_mail(inbound_mail, channel)
-
end
-
rescue OAuth2::Error => e
-
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
-
channel.authorization_error!
-
end
-
-
def process_mail(inbound_mail, channel)
-
Imap::ImapMailbox.new.process(inbound_mail, channel)
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
-
Rails.logger.error("
-
#{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
-
end
-
end
-
class Inboxes::SyncWidgetPreChatCustomFieldsJob < ApplicationJob
-
queue_as :default
-
-
def perform(account, field_name)
-
account.web_widgets.all.find_each do |web_widget|
-
pre_chat_fields = web_widget.pre_chat_form_options['pre_chat_fields']
-
web_widget.pre_chat_form_options['pre_chat_fields'] = pre_chat_fields.reject { |field| field['name'] == field_name }
-
web_widget.save!
-
end
-
end
-
end
-
class Inboxes::UpdateWidgetPreChatCustomFieldsJob < ApplicationJob
-
queue_as :default
-
-
def perform(account, custom_attribute)
-
attribute_key = custom_attribute['attribute_key']
-
account.web_widgets.all.find_each do |web_widget|
-
pre_chat_fields = web_widget.pre_chat_form_options['pre_chat_fields']
-
pre_chat_fields.each_with_index do |pre_chat_field, index|
-
next unless pre_chat_field['name'] == attribute_key
-
-
web_widget.pre_chat_form_options['pre_chat_fields'][index] =
-
pre_chat_field.deep_merge({
-
'label' => custom_attribute['attribute_display_name'],
-
'placeholder' => custom_attribute['attribute_display_name'],
-
'values' => custom_attribute['attribute_values'],
-
'regex_pattern' => custom_attribute['regex_pattern'],
-
'regex_cue' => custom_attribute['regex_cue']
-
})
-
end
-
web_widget.save!
-
end
-
end
-
end
-
class Internal::CheckNewVersionsJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
return unless Rails.env.production?
-
-
@instance_info = ChatwootHub.sync_with_hub
-
update_version_info
-
end
-
-
private
-
-
def update_version_info
-
return if @instance_info['version'].blank?
-
-
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, @instance_info['version'])
-
end
-
end
-
-
Internal::CheckNewVersionsJob.prepend_mod_with('Internal::CheckNewVersionsJob')
-
# housekeeping
-
# remove stale contacts for all accounts
-
# - have no identification (email, phone_number, and identifier are NULL)
-
# - have no conversations
-
# - are older than 30 days
-
-
class Internal::ProcessStaleContactsJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
return unless ChatwootApp.chatwoot_cloud?
-
-
Account.find_in_batches(batch_size: 100) do |accounts|
-
accounts.each do |account|
-
Rails.logger.info "Enqueuing RemoveStaleContactsJob for account #{account.id}"
-
Internal::RemoveStaleContactsJob.perform_later(account)
-
end
-
end
-
end
-
end
-
# housekeeping
-
# remove contact inboxes that does not have any conversations
-
# and are older than 3 months
-
-
class Internal::ProcessStaleRedisKeysJob < ApplicationJob
-
queue_as :low
-
-
def perform(account)
-
removed_count = Internal::RemoveStaleRedisKeysService.new(account_id: account.id).perform
-
Rails.logger.info "Successfully cleaned up Redis keys for account #{account.id} (removed #{removed_count} keys)"
-
end
-
end
-
# housekeeping
-
# remove contact inboxes that does not have any conversations
-
# and are older than 3 months
-
-
class Internal::RemoveStaleContactInboxesJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
Internal::RemoveStaleContactInboxesService.new.perform
-
end
-
end
-
# housekeeping
-
# remove contacts that:
-
# - have no identification (email, phone_number, and identifier are NULL)
-
# - have no conversations
-
# - are older than 30 days
-
-
class Internal::RemoveStaleContactsJob < ApplicationJob
-
queue_as :low
-
-
def perform(account, batch_size = 1000)
-
Internal::RemoveStaleContactsService.new(account: account).perform(batch_size)
-
end
-
end
-
# housekeeping
-
# ensure stale ONLINE PRESENCE KEYS for contacts are removed periodically
-
# should result in 50% redis mem size reduction
-
-
class Internal::RemoveStaleRedisKeysJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
Account.find_in_batches(batch_size: 100) do |accounts|
-
accounts.each do |account|
-
Rails.logger.info "Enqueuing ProcessStaleRedisKeysJob for account #{account.id}"
-
Internal::ProcessStaleRedisKeysJob.perform_later(account)
-
end
-
end
-
end
-
end
-
class Internal::SeedAccountJob < ApplicationJob
-
queue_as :low
-
-
def perform(account)
-
Seeders::AccountSeeder.new(account: account).perform!
-
end
-
end
-
class Labels::UpdateJob < ApplicationJob
-
queue_as :default
-
-
def perform(new_label_title, old_label_title, account_id)
-
Labels::UpdateService.new(
-
new_label_title: new_label_title,
-
old_label_title: old_label_title,
-
account_id: account_id
-
).perform
-
end
-
end
-
class MacrosExecutionJob < ApplicationJob
-
queue_as :medium
-
-
def perform(macro, conversation_ids:, user:)
-
account = macro.account
-
conversations = account.conversations.where(display_id: conversation_ids.to_a)
-
-
return if conversations.blank?
-
-
conversations.each do |conversation|
-
::Macros::ExecutionService.new(macro, conversation, user).perform
-
end
-
end
-
end
-
# Delete migration and spec after 2 consecutive releases.
-
class Migration::AddSearchIndexesJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
ActiveRecord::Migration[6.1].add_index(:messages, [:account_id, :inbox_id], algorithm: :concurrently)
-
ActiveRecord::Migration[6.1].add_index(:messages, :content, using: 'gin', opclass: :gin_trgm_ops, algorithm: :concurrently)
-
ActiveRecord::Migration[6.1].add_index(
-
:contacts,
-
[:name, :email, :phone_number, :identifier],
-
using: 'gin',
-
opclass: :gin_trgm_ops,
-
name: 'index_contacts_on_name_email_phone_number_identifier',
-
algorithm: :concurrently
-
)
-
end
-
end
-
class Migration::ConversationBatchCacheLabelJob < ApplicationJob
-
queue_as :async_database_migration
-
-
# To cache the label, we simply access it from the object and save it. Anytime the object is
-
# saved in the future, ActsAsTaggable will automatically recompute it. This process is done
-
# initially when the user has not performed any action.
-
# Reference: https://github.com/mbleigh/acts-as-taggable-on/wiki/Caching
-
def perform(conversation_batch)
-
conversation_batch.each do |conversation|
-
conversation.label_list
-
conversation.save!
-
end
-
end
-
end
-
class Migration::ConversationCacheLabelJob < ApplicationJob
-
queue_as :async_database_migration
-
-
def perform(account)
-
account.conversations.find_in_batches(batch_size: 100) do |conversation_batch|
-
Migration::ConversationBatchCacheLabelJob.perform_later(conversation_batch)
-
end
-
end
-
end
-
# Delete migration and spec after 2 consecutive releases.
-
class Migration::ConversationsFirstReplySchedulerJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform(account)
-
account.conversations.each do |conversation|
-
# rubocop:disable Rails/SkipsModelValidations
-
if conversation.messages.outgoing.where("(additional_attributes->'campaign_id') is null").count.positive?
-
conversation.update_columns(first_reply_created_at: conversation.messages.outgoing.where("(additional_attributes->'campaign_id') is null")
-
.first.created_at)
-
else
-
conversation.update_columns(first_reply_created_at: nil)
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
end
-
end
-
# Delete migration and spec after 2 consecutive releases.
-
class Migration::RemoveMessageNotifications < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
Notification.where(primary_actor_type: 'Message').in_batches(of: 100).delete_all
-
end
-
end
-
# Delete migration and spec after 2 consecutive releases.
-
class Migration::RemoveStaleNotificationsJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
remove_invalid_messages
-
end
-
-
private
-
-
def remove_invalid_messages
-
deleted_ids = []
-
-
Message.unscoped.distinct.pluck(:inbox_id).each_slice(1000) do |id_list|
-
deleted_ids = (id_list - Inbox.where(id: id_list).pluck(:id))
-
Message.where(inbox_id: deleted_ids.flatten).destroy_all
-
end
-
end
-
end
-
# Delete migration and spec after 2 consecutive releases.
-
class Migration::UpdateFirstResponseTimeInReportingEventsJob < ApplicationJob
-
include ReportingEventHelper
-
-
queue_as :async_database_migration
-
-
def perform(account)
-
get_conversations_with_bot_handoffs(account)
-
account.reporting_events.where(name: 'first_response').find_each do |event|
-
conversation = event.conversation
-
-
# if the conversation has a bot handoff event, we don't need to update the response_time
-
next if conversation.nil? || @conversations_with_handoffs.include?(conversation.id)
-
-
update_event_data(event, conversation)
-
end
-
end
-
-
def get_conversations_with_bot_handoffs(account)
-
@conversations_with_handoffs = account.reporting_events.where(name: 'conversation_bot_handoff').pluck(:conversation_id)
-
end
-
-
def update_event_data(event, conversation)
-
last_bot_reply = conversation.messages.where(sender_type: 'AgentBot').order(created_at: :asc).last
-
return if last_bot_reply.blank?
-
-
first_human_reply = conversation.messages.where(sender_type: 'User').order(created_at: :asc).first
-
return if first_human_reply.blank?
-
-
# accomodate for campaign if required
-
# new_value = difference between the first_human_reply and the first_bot_reply if it exists or first_human_reply and created at
-
#
-
# conversation bot conversation
-
# start handoff resolved
-
# | | |
-
# |____|___|_________|____|_______|_____|________|
-
# bot reply ^ ^ human reply
-
# | |
-
# | |
-
# last_bot_reply first_human_reply
-
#
-
#
-
# bot handoff happens at the last_bot_reply created time
-
# the response time is the time between last bot reply created and the first human reply created
-
return if last_bot_reply.created_at.to_i >= first_human_reply.created_at.to_i
-
-
# this means a bot replied existed, so we need to update the event_start_time
-
update_event_details(event, last_bot_reply, first_human_reply, conversation.inbox)
-
end
-
-
def update_event_details(event, last_bot_reply, first_human_reply, inbox)
-
# rubocop:disable Rails/SkipsModelValidations
-
event.update_columns(event_start_time: last_bot_reply.created_at,
-
event_end_time: first_human_reply.created_at,
-
value: calculate_event_value(last_bot_reply, first_human_reply),
-
value_in_business_hours: calculate_event_value_in_business_hours(inbox, last_bot_reply,
-
first_human_reply),
-
user_id: event.user_id || first_human_reply.sender_id)
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
-
def calculate_event_value(last_bot_reply, first_human_reply)
-
first_human_reply.created_at.to_i - last_bot_reply.created_at.to_i
-
end
-
-
def calculate_event_value_in_business_hours(inbox, last_bot_reply, first_human_reply)
-
business_hours(inbox, last_bot_reply.created_at, first_human_reply.created_at)
-
end
-
end
-
# MutexApplicationJob serves as a base class for jobs that require distributed locking mechanisms.
-
# It abstracts the locking logic using Redis and ensures that a block of code can be executed with
-
# mutual exclusion.
-
#
-
# The primary mechanism provided is the `with_lock` method, which accepts a key format and associated
-
# arguments. This method attempts to acquire a lock using the generated key, and if successful, it
-
# executes the provided block of code. If the lock cannot be acquired, it raises a LockAcquisitionError.
-
#
-
# To use this class, inherit from MutexApplicationJob and make use of the `with_lock` method in the
-
# `perform` method of the derived job class.
-
#
-
# Also see, retry mechanism here: https://edgeapi.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on
-
#
-
class MutexApplicationJob < ApplicationJob
-
class LockAcquisitionError < StandardError; end
-
-
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
-
lock_manager = Redis::LockManager.new
-
-
begin
-
if lock_manager.lock(lock_key, timeout)
-
log_attempt(lock_key, executions)
-
yield
-
# release the lock after the block has been executed
-
lock_manager.unlock(lock_key)
-
else
-
handle_failed_lock_acquisition(lock_key)
-
end
-
rescue StandardError => e
-
handle_error(e, lock_manager, lock_key)
-
end
-
end
-
-
private
-
-
def log_attempt(lock_key, executions)
-
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
-
end
-
-
def handle_error(err, lock_manager, lock_key)
-
lock_manager.unlock(lock_key) unless err.is_a?(LockAcquisitionError)
-
raise err
-
end
-
-
def handle_failed_lock_acquisition(lock_key)
-
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
-
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
-
end
-
end
-
class Notification::DeleteNotificationJob < ApplicationJob
-
queue_as :low
-
-
def perform(user, type: :all)
-
ActiveRecord::Base.transaction do
-
if type == :all
-
# Delete all notifications
-
user.notifications.destroy_all
-
elsif type == :read
-
# Delete only read notifications
-
user.notifications.where.not(read_at: nil).destroy_all
-
end
-
end
-
end
-
end
-
class Notification::EmailNotificationJob < ApplicationJob
-
queue_as :default
-
-
def perform(notification)
-
# no need to send email if notification has been read already
-
return if notification.read_at.present?
-
-
Notification::EmailNotificationService.new(notification: notification).perform
-
end
-
end
-
class Notification::PushNotificationJob < ApplicationJob
-
queue_as :default
-
-
def perform(notification)
-
Notification::PushNotificationService.new(notification: notification).perform
-
end
-
end
-
class Notification::RemoveDuplicateNotificationJob < ApplicationJob
-
queue_as :default
-
-
def perform(notification)
-
return unless notification.is_a?(Notification)
-
-
user_id = notification.user_id
-
primary_actor_id = notification.primary_actor_id
-
-
# Find older notifications with the same user and primary_actor_id
-
duplicate_notifications = Notification.where(user_id: user_id, primary_actor_id: primary_actor_id)
-
.order(created_at: :desc)
-
-
# Skip the first one (the latest notification) and destroy the rest
-
duplicate_notifications.offset(1).each(&:destroy)
-
end
-
end
-
class Notification::RemoveOldNotificationJob < ApplicationJob
-
queue_as :low
-
-
def perform
-
Notification.where('created_at < ?', 1.month.ago)
-
.find_each(batch_size: 1000, &:delete)
-
end
-
end
-
class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
-
queue_as :low
-
-
def perform
-
Notification.where(snoozed_until: 3.days.ago..Time.current).find_in_batches(batch_size: 100) do |notifications_batch|
-
notifications_batch.each do |notification|
-
update_notification(notification)
-
end
-
end
-
end
-
-
private
-
-
def update_notification(notification)
-
updated_meta = (notification.meta || {}).merge('last_snoozed_at' => notification.snoozed_until)
-
-
notification.update!(
-
snoozed_until: nil,
-
updated_at: Time.current,
-
last_activity_at: Time.current,
-
meta: updated_meta,
-
read_at: nil
-
)
-
end
-
end
-
class SendOnSlackJob < MutexApplicationJob
-
queue_as :medium
-
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
-
-
def perform(message, hook)
-
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
-
with_lock(key) do
-
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
-
end
-
end
-
end
-
1
class SendReplyJob < ApplicationJob
-
1
queue_as :high
-
-
1
def perform(message_id)
-
message = Message.find(message_id)
-
conversation = message.conversation
-
channel_name = conversation.inbox.channel.class.to_s
-
-
services = {
-
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
-
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
-
'Channel::Line' => ::Line::SendOnLineService,
-
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
-
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
-
'Channel::Sms' => ::Sms::SendOnSmsService,
-
'Channel::Instagram' => ::Instagram::SendOnInstagramService
-
}
-
-
case channel_name
-
when 'Channel::FacebookPage'
-
send_on_facebook_page(message)
-
else
-
services[channel_name].new(message: message).perform if services[channel_name].present?
-
end
-
end
-
-
1
private
-
-
1
def send_on_facebook_page(message)
-
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
-
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
-
else
-
::Facebook::SendOnFacebookService.new(message: message).perform
-
end
-
end
-
end
-
class SlackUnfurlJob < ApplicationJob
-
queue_as :low
-
-
def perform(params)
-
@params = params
-
set_integration_hook
-
-
return unless channel_has_access
-
-
Integrations::Slack::SlackLinkUnfurlService.new(params: @params, integration_hook: @integration_hook).perform
-
end
-
-
private
-
-
# Find the integration hook by taking first link from array of links
-
# Assume that all the links are from the same account, how ever there is a possibility that the links are from different accounts.
-
# TODO: Fix this edge case later
-
def set_integration_hook
-
url = extract_url
-
return unless url
-
-
account_id = extract_account_id(url)
-
@integration_hook = Integrations::Hook.find_by(account_id: account_id, app_id: 'slack')
-
end
-
-
def extract_url
-
@params.dig(:event, :links)&.first&.[](:url)
-
end
-
-
def extract_account_id(url)
-
account_id_regex = %r{/accounts/(\d+)}
-
match_data = url.match(account_id_regex)
-
match_data[1] if match_data
-
end
-
-
# Check the channel has access to the bot to unfurl the links
-
def channel_has_access
-
return if @integration_hook.blank?
-
-
slack_client = Slack::Web::Client.new(token: @integration_hook.access_token)
-
response = slack_client.conversations_members(channel: @params.dig(:event, :channel))
-
response['ok']
-
rescue Slack::Web::Api::Errors::ChannelNotFound => e
-
# The link unfurl event will not work for private channels and other accounts channels
-
# So we can ignore the error
-
Rails.logger.error "Exception in SlackUnfurlJob: #{e.message}"
-
false
-
end
-
end
-
class TriggerScheduledItemsJob < ApplicationJob
-
queue_as :scheduled_jobs
-
-
def perform
-
# trigger the scheduled campaign jobs
-
Campaign.where(campaign_type: :one_off,
-
campaign_status: :active).where(scheduled_at: 3.days.ago..Time.current).all.find_each(batch_size: 100) do |campaign|
-
Campaigns::TriggerOneoffCampaignJob.perform_later(campaign)
-
end
-
-
# Job to reopen snoozed conversations
-
Conversations::ReopenSnoozedConversationsJob.perform_later
-
-
# Job to reopen snoozed notifications
-
Notification::ReopenSnoozedNotificationsJob.perform_later
-
-
# Job to auto-resolve conversations
-
Account::ConversationsResolutionSchedulerJob.perform_later
-
-
# Job to sync whatsapp templates
-
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
-
-
# Job to clear notifications which are older than 1 month
-
Notification::RemoveOldNotificationJob.perform_later
-
end
-
end
-
-
TriggerScheduledItemsJob.prepend_mod_with('TriggerScheduledItemsJob')
-
class WebhookJob < ApplicationJob
-
queue_as :medium
-
# There are 3 types of webhooks, account, inbox and agent_bot
-
def perform(url, payload, webhook_type = :account_webhook)
-
Webhooks::Trigger.execute(url, payload, webhook_type)
-
end
-
end
-
class Webhooks::FacebookDeliveryJob < ApplicationJob
-
queue_as :low
-
-
def perform(message)
-
response = ::Integrations::Facebook::MessageParser.new(message)
-
Integrations::Facebook::DeliveryStatus.new(params: response).perform
-
end
-
end
-
class Webhooks::FacebookEventsJob < MutexApplicationJob
-
queue_as :default
-
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
-
-
def perform(message)
-
response = ::Integrations::Facebook::MessageParser.new(message)
-
-
key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id)
-
with_lock(key) do
-
process_message(response)
-
end
-
end
-
-
def process_message(response)
-
::Integrations::Facebook::MessageCreator.new(response).perform
-
end
-
end
-
class Webhooks::InstagramEventsJob < MutexApplicationJob
-
queue_as :default
-
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
-
-
# @return [Array] We will support further events like reaction or seen in future
-
SUPPORTED_EVENTS = [:message, :read].freeze
-
-
def perform(entries)
-
@entries = entries
-
-
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
-
with_lock(key) do
-
process_entries(entries)
-
end
-
end
-
-
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
-
def process_entries(entries)
-
entries.each do |entry|
-
process_single_entry(entry.with_indifferent_access)
-
end
-
end
-
-
private
-
-
def process_single_entry(entry)
-
if test_event?(entry)
-
process_test_event(entry)
-
return
-
end
-
-
process_messages(entry)
-
end
-
-
def process_messages(entry)
-
messages(entry).each do |messaging|
-
Rails.logger.info("Instagram Events Job Messaging: #{messaging}")
-
-
instagram_id = instagram_id(messaging)
-
channel = find_channel(instagram_id)
-
-
next if channel.blank?
-
-
if (event_name = event_name(messaging))
-
send(event_name, messaging, channel)
-
end
-
end
-
end
-
-
def agent_message_via_echo?(messaging)
-
messaging[:message].present? && messaging[:message][:is_echo].present?
-
end
-
-
def test_event?(entry)
-
entry[:changes].present?
-
end
-
-
def process_test_event(entry)
-
messaging = extract_messaging_from_test_event(entry)
-
-
Instagram::TestEventService.new(messaging).perform if messaging.present?
-
end
-
-
def extract_messaging_from_test_event(entry)
-
entry[:changes].first&.dig(:value) if entry[:changes].present?
-
end
-
-
def instagram_id(messaging)
-
if agent_message_via_echo?(messaging)
-
messaging[:sender][:id]
-
else
-
messaging[:recipient][:id]
-
end
-
end
-
-
def ig_account_id
-
@entries&.first&.dig(:id)
-
end
-
-
def sender_id
-
@entries&.dig(0, :messaging, 0, :sender, :id)
-
end
-
-
def find_channel(instagram_id)
-
# There will be chances for the instagram account to be connected to a facebook page,
-
# so we need to check for both instagram and facebook page channels
-
# priority is for instagram channel which created via instagram login
-
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
-
# If not found, fallback to the facebook page channel
-
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
-
-
channel
-
end
-
-
def event_name(messaging)
-
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
-
end
-
-
def message(messaging, channel)
-
if channel.is_a?(Channel::Instagram)
-
::Instagram::MessageText.new(messaging, channel).perform
-
else
-
::Instagram::Messenger::MessageText.new(messaging, channel).perform
-
end
-
end
-
-
def read(messaging, channel)
-
# Use a single service to handle read status for both channel types since the params are same
-
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
-
end
-
-
def messages(entry)
-
(entry[:messaging].presence || entry[:standby] || [])
-
end
-
end
-
-
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
-
# [
-
# {
-
# "time": <timestamp>,
-
# "id": <INSTAGRAM_USER_ID>,
-
# "messaging": [
-
# {
-
# "sender": {
-
# "id": <INSTAGRAM_USER_ID>
-
# },
-
# "recipient": {
-
# "id": <INSTAGRAM_USER_ID>
-
# },
-
# "timestamp": <timestamp>,
-
# "message": {
-
# "mid": <MESSAGE_ID>,
-
# "text": <MESSAGE_TEXT>
-
# }
-
# }
-
# ]
-
# }
-
# ]
-
-
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
-
# When testing in Facebook's developer dashboard, you'll get a Page-style
-
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
-
# arrive in the familiar Messenger format with a "messaging" array.
-
# This apparent inconsistency is actually by design - Instagram's webhooks
-
# use different formats for testing vs production to maintain compatibility
-
# with both Instagram Direct and Facebook Page integrations.
-
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
-
-
# Test response from via Instagram direct
-
# [
-
# {
-
# "id": "0",
-
# "time": <timestamp>,
-
# "changes": [
-
# {
-
# "field": "messages",
-
# "value": {
-
# "sender": {
-
# "id": "12334"
-
# },
-
# "recipient": {
-
# "id": "23245"
-
# },
-
# "timestamp": "1527459824",
-
# "message": {
-
# "mid": "random_mid",
-
# "text": "random_text"
-
# }
-
# }
-
# }
-
# ]
-
# }
-
# ]
-
-
# Test response via Facebook page
-
# [
-
# {
-
# "time": <timestamp>,,
-
# "id": "0",
-
# "messaging": [
-
# {
-
# "sender": {
-
# "id": "12334"
-
# },
-
# "recipient": {
-
# "id": "23245"
-
# },
-
# "timestamp": <timestamp>,
-
# "message": {
-
# "mid": "random_mid",
-
# "text": "random_text"
-
# }
-
# }
-
# ]
-
# }
-
# ]
-
class Webhooks::LineEventsJob < ApplicationJob
-
queue_as :default
-
-
def perform(params: {}, signature: '', post_body: '')
-
@params = params
-
return unless valid_event_payload?
-
return unless valid_post_body?(post_body, signature)
-
-
Line::IncomingMessageService.new(inbox: @channel.inbox, params: @params['line'].with_indifferent_access).perform
-
end
-
-
private
-
-
def valid_event_payload?
-
@channel = Channel::Line.find_by(line_channel_id: @params[:line_channel_id]) if @params[:line_channel_id]
-
end
-
-
# https://developers.line.biz/en/reference/messaging-api/#signature-validation
-
# validate the line payload
-
def valid_post_body?(post_body, signature)
-
hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @channel.line_channel_secret, post_body)
-
Base64.strict_encode64(hash) == signature
-
end
-
end
-
class Webhooks::SmsEventsJob < ApplicationJob
-
queue_as :default
-
-
SUPPORTED_EVENTS = %w[message-received message-delivered message-failed].freeze
-
-
def perform(params = {})
-
return unless SUPPORTED_EVENTS.include?(params[:type])
-
-
channel = Channel::Sms.find_by(phone_number: params[:to])
-
return unless channel
-
-
process_event_params(channel, params)
-
end
-
-
private
-
-
def process_event_params(channel, params)
-
if delivery_event?(params)
-
Sms::DeliveryStatusService.new(channel: channel, params: params[:message].with_indifferent_access).perform
-
else
-
Sms::IncomingMessageService.new(inbox: channel.inbox, params: params[:message].with_indifferent_access).perform
-
end
-
end
-
-
def delivery_event?(params)
-
params[:type] == 'message-delivered' || params[:type] == 'message-failed'
-
end
-
end
-
class Webhooks::TelegramEventsJob < ApplicationJob
-
queue_as :default
-
-
def perform(params = {})
-
return unless params[:bot_token]
-
-
channel = Channel::Telegram.find_by(bot_token: params[:bot_token])
-
-
if channel_is_inactive?(channel)
-
log_inactive_channel(channel, params)
-
return
-
end
-
-
process_event_params(channel, params)
-
end
-
-
private
-
-
def channel_is_inactive?(channel)
-
return true if channel.blank?
-
return true unless channel.account.active?
-
-
false
-
end
-
-
def log_inactive_channel(channel, params)
-
message = if channel&.id
-
"Account #{channel.account.id} is not active for channel #{channel.id}"
-
else
-
"Channel not found for bot_token: #{params[:bot_token]}"
-
end
-
Rails.logger.warn("Telegram event discarded: #{message}")
-
end
-
-
def process_event_params(channel, params)
-
return unless params[:telegram]
-
-
if params.dig(:telegram, :edited_message).present?
-
Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
-
else
-
Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
-
end
-
end
-
end
-
class Webhooks::TwilioDeliveryStatusJob < ApplicationJob
-
queue_as :low
-
-
def perform(params = {})
-
::Twilio::DeliveryStatusService.new(params: params).perform
-
end
-
end
-
class Webhooks::TwilioEventsJob < ApplicationJob
-
queue_as :low
-
-
def perform(params = {})
-
# Skip processing if Body parameter or MediaUrl0 is not present
-
# This is to skip processing delivery events being delivered to this endpoint
-
return if params[:Body].blank? && params[:MediaUrl0].blank?
-
-
::Twilio::IncomingMessageService.new(params: params).perform
-
end
-
end
-
class Webhooks::WhatsappEventsJob < ApplicationJob
-
queue_as :low
-
-
def perform(params = {})
-
channel = find_channel_from_whatsapp_business_payload(params)
-
-
if channel_is_inactive?(channel)
-
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
-
return
-
end
-
-
case channel.provider
-
when 'whatsapp_cloud'
-
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
-
else
-
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
-
end
-
end
-
-
private
-
-
def channel_is_inactive?(channel)
-
return true if channel.blank?
-
return true if channel.reauthorization_required?
-
return true unless channel.account.active?
-
-
false
-
end
-
-
def find_channel_by_url_param(params)
-
return unless params[:phone_number]
-
-
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
-
end
-
-
def find_channel_from_whatsapp_business_payload(params)
-
# for the case where facebook cloud api support multiple numbers for a single app
-
# https://github.com/chatwoot/chatwoot/issues/4712#issuecomment-1173838350
-
# we will give priority to the phone_number in the payload
-
return get_channel_from_wb_payload(params) if params[:object] == 'whatsapp_business_account'
-
-
find_channel_by_url_param(params)
-
end
-
-
def get_channel_from_wb_payload(wb_params)
-
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
-
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
-
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
-
# validate to ensure the phone number id matches the whatsapp channel
-
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
-
end
-
end
-
1
class ActionCableListener < BaseListener
-
1
include Events::Types
-
-
1
def notification_created(event)
-
notification, account, unread_count, count = extract_notification_and_account(event)
-
tokens = [event.data[:notification].user.pubsub_token]
-
broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
-
end
-
-
1
def notification_updated(event)
-
notification, account, unread_count, count = extract_notification_and_account(event)
-
tokens = [event.data[:notification].user.pubsub_token]
-
broadcast(account, tokens, NOTIFICATION_UPDATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
-
end
-
-
1
def notification_deleted(event)
-
return if event.data[:notification].user.blank?
-
-
notification, account, unread_count, count = extract_notification_and_account(event)
-
tokens = [event.data[:notification].user.pubsub_token]
-
broadcast(account, tokens, NOTIFICATION_DELETED, { notification: { id: notification.id }, unread_count: unread_count, count: count })
-
end
-
-
1
def account_cache_invalidated(event)
-
9
account = event.data[:account]
-
9
tokens = user_tokens(account, account.agents)
-
-
9
broadcast(account, tokens, ACCOUNT_CACHE_INVALIDATED, {
-
cache_keys: event.data[:cache_keys]
-
})
-
end
-
-
1
def message_created(event)
-
6
message, account = extract_message_and_account(event)
-
6
conversation = message.conversation
-
6
tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)
-
-
6
broadcast(account, tokens, MESSAGE_CREATED, message.push_event_data)
-
end
-
-
1
def message_updated(event)
-
message, account = extract_message_and_account(event)
-
conversation = message.conversation
-
tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)
-
-
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data.merge(previous_changes: event.data[:previous_changes]))
-
end
-
-
1
def first_reply_created(event)
-
3
message, account = extract_message_and_account(event)
-
3
conversation = message.conversation
-
3
tokens = user_tokens(account, conversation.inbox.members)
-
-
3
broadcast(account, tokens, FIRST_REPLY_CREATED, message.push_event_data)
-
end
-
-
1
def conversation_created(event)
-
3
conversation, account = extract_conversation_and_account(event)
-
3
tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)
-
-
3
broadcast(account, tokens, CONVERSATION_CREATED, conversation.push_event_data)
-
end
-
-
1
def conversation_read(event)
-
conversation, account = extract_conversation_and_account(event)
-
tokens = user_tokens(account, conversation.inbox.members)
-
-
broadcast(account, tokens, CONVERSATION_READ, conversation.push_event_data)
-
end
-
-
1
def conversation_status_changed(event)
-
conversation, account = extract_conversation_and_account(event)
-
tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)
-
-
broadcast(account, tokens, CONVERSATION_STATUS_CHANGED, conversation.push_event_data)
-
end
-
-
1
def conversation_updated(event)
-
6
conversation, account = extract_conversation_and_account(event)
-
6
tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)
-
-
6
broadcast(account, tokens, CONVERSATION_UPDATED, conversation.push_event_data)
-
end
-
-
1
def conversation_typing_on(event)
-
conversation = event.data[:conversation]
-
account = conversation.account
-
user = event.data[:user]
-
tokens = typing_event_listener_tokens(account, conversation, user)
-
-
broadcast(
-
account,
-
tokens,
-
CONVERSATION_TYPING_ON,
-
conversation: conversation.push_event_data,
-
user: user.push_event_data,
-
is_private: event.data[:is_private] || false
-
)
-
end
-
-
1
def conversation_typing_off(event)
-
conversation = event.data[:conversation]
-
account = conversation.account
-
user = event.data[:user]
-
tokens = typing_event_listener_tokens(account, conversation, user)
-
-
broadcast(
-
account,
-
tokens,
-
CONVERSATION_TYPING_OFF,
-
conversation: conversation.push_event_data,
-
user: user.push_event_data,
-
is_private: event.data[:is_private] || false
-
)
-
end
-
-
1
def assignee_changed(event)
-
conversation, account = extract_conversation_and_account(event)
-
tokens = user_tokens(account, conversation.inbox.members)
-
-
broadcast(account, tokens, ASSIGNEE_CHANGED, conversation.push_event_data)
-
end
-
-
1
def team_changed(event)
-
conversation, account = extract_conversation_and_account(event)
-
tokens = user_tokens(account, conversation.inbox.members)
-
-
broadcast(account, tokens, TEAM_CHANGED, conversation.push_event_data)
-
end
-
-
1
def conversation_contact_changed(event)
-
3
conversation, account = extract_conversation_and_account(event)
-
3
tokens = user_tokens(account, conversation.inbox.members)
-
-
3
broadcast(account, tokens, CONVERSATION_CONTACT_CHANGED, conversation.push_event_data)
-
end
-
-
1
def contact_created(event)
-
6
contact, account = extract_contact_and_account(event)
-
6
broadcast(account, [account_token(account)], CONTACT_CREATED, contact.push_event_data)
-
end
-
-
1
def contact_updated(event)
-
3
contact, account = extract_contact_and_account(event)
-
3
broadcast(account, [account_token(account)], CONTACT_UPDATED, contact.push_event_data)
-
end
-
-
1
def contact_merged(event)
-
contact, account = extract_contact_and_account(event)
-
broadcast(account, [account_token(account)], CONTACT_MERGED, contact.push_event_data)
-
end
-
-
1
def contact_deleted(event)
-
contact, account = extract_contact_and_account(event)
-
broadcast(account, [account_token(account)], CONTACT_DELETED, contact.push_event_data)
-
end
-
-
1
def conversation_mentioned(event)
-
conversation, account = extract_conversation_and_account(event)
-
user = event.data[:user]
-
-
broadcast(account, [user.pubsub_token], CONVERSATION_MENTIONED, conversation.push_event_data)
-
end
-
-
1
private
-
-
1
def account_token(account)
-
9
"account_#{account.id}"
-
end
-
-
1
def typing_event_listener_tokens(account, conversation, user)
-
current_user_token = user.is_a?(Contact) ? conversation.contact_inbox.pubsub_token : user.pubsub_token
-
(user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]) - [current_user_token]
-
end
-
-
1
def user_tokens(account, agents)
-
30
agent_tokens = agents.pluck(:pubsub_token)
-
30
admin_tokens = account.administrators.pluck(:pubsub_token)
-
30
(agent_tokens + admin_tokens).uniq
-
end
-
-
1
def contact_tokens(contact_inbox, message)
-
6
return [] if message.private?
-
6
return [] if message.activity?
-
6
return [] if contact_inbox.nil?
-
-
6
contact_inbox_tokens(contact_inbox)
-
end
-
-
1
def contact_inbox_tokens(contact_inbox)
-
15
contact = contact_inbox.contact
-
-
15
contact_inbox.hmac_verified? ? contact.contact_inboxes.where(hmac_verified: true).filter_map(&:pubsub_token) : [contact_inbox.pubsub_token]
-
end
-
-
1
def broadcast(account, tokens, event_name, data)
-
39
return if tokens.blank?
-
-
24
payload = data.merge(account_id: account.id)
-
# So the frondend knows who performed the action.
-
# Useful in cases like conversation assignment for generating a notification with assigner name.
-
24
payload[:performer] = Current.user&.push_event_data if Current.user.present?
-
-
24
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
-
end
-
end
-
1
class AgentBotListener < BaseListener
-
1
def conversation_resolved(event)
-
conversation = extract_conversation_and_account(event)[0]
-
inbox = conversation.inbox
-
return unless connected_agent_bot_exist?(inbox)
-
-
event_name = __method__.to_s
-
payload = conversation.webhook_data.merge(event: event_name)
-
process_webhook_bot_event(inbox.agent_bot, payload)
-
end
-
-
1
def conversation_opened(event)
-
conversation = extract_conversation_and_account(event)[0]
-
inbox = conversation.inbox
-
return unless connected_agent_bot_exist?(inbox)
-
-
event_name = __method__.to_s
-
payload = conversation.webhook_data.merge(event: event_name)
-
process_webhook_bot_event(inbox.agent_bot, payload)
-
end
-
-
1
def message_created(event)
-
6
message = extract_message_and_account(event)[0]
-
6
inbox = message.inbox
-
6
return unless connected_agent_bot_exist?(inbox)
-
return unless message.webhook_sendable?
-
-
method_name = __method__.to_s
-
process_message_event(method_name, inbox.agent_bot, message, event)
-
end
-
-
1
def message_updated(event)
-
message = extract_message_and_account(event)[0]
-
inbox = message.inbox
-
return unless connected_agent_bot_exist?(inbox)
-
return unless message.webhook_sendable?
-
-
method_name = __method__.to_s
-
process_message_event(method_name, inbox.agent_bot, message, event)
-
end
-
-
1
def webwidget_triggered(event)
-
contact_inbox = event.data[:contact_inbox]
-
inbox = contact_inbox.inbox
-
return unless connected_agent_bot_exist?(inbox)
-
-
event_name = __method__.to_s
-
payload = contact_inbox.webhook_data.merge(event: event_name)
-
payload[:event_info] = event.data[:event_info]
-
process_webhook_bot_event(inbox.agent_bot, payload)
-
end
-
-
1
private
-
-
1
def connected_agent_bot_exist?(inbox)
-
6
return if inbox.agent_bot_inbox.blank?
-
return unless inbox.agent_bot_inbox.active?
-
-
true
-
end
-
-
1
def process_message_event(method_name, agent_bot, message, _event)
-
# Only webhook bots are supported
-
payload = message.webhook_data.merge(event: method_name)
-
process_webhook_bot_event(agent_bot, payload)
-
end
-
-
1
def process_webhook_bot_event(agent_bot, payload)
-
return if agent_bot.outgoing_url.blank?
-
-
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
-
end
-
end
-
1
class AutomationRuleListener < BaseListener
-
1
def conversation_updated(event)
-
return if performed_by_automation?(event)
-
-
conversation = event.data[:conversation]
-
account = conversation.account
-
changed_attributes = event.data[:changed_attributes]
-
-
return unless rule_present?('conversation_updated', account)
-
-
rules = current_account_rules('conversation_updated', account)
-
-
rules.each do |rule|
-
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
-
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
-
end
-
end
-
-
1
def conversation_created(event)
-
return if performed_by_automation?(event)
-
-
conversation = event.data[:conversation]
-
account = conversation.account
-
changed_attributes = event.data[:changed_attributes]
-
-
return unless rule_present?('conversation_created', account)
-
-
rules = current_account_rules('conversation_created', account)
-
-
rules.each do |rule|
-
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
-
::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
-
end
-
end
-
-
1
def conversation_opened(event)
-
return if performed_by_automation?(event)
-
-
conversation = event.data[:conversation]
-
account = conversation.account
-
changed_attributes = event.data[:changed_attributes]
-
-
return unless rule_present?('conversation_opened', account)
-
-
rules = current_account_rules('conversation_opened', account)
-
-
rules.each do |rule|
-
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
-
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
-
end
-
end
-
-
1
def message_created(event)
-
message = event.data[:message]
-
-
return if ignore_message_created_event?(event)
-
-
account = message.try(:account)
-
changed_attributes = event.data[:changed_attributes]
-
-
return unless rule_present?('message_created', account)
-
-
rules = current_account_rules('message_created', account)
-
-
rules.each do |rule|
-
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
-
{ message: message, changed_attributes: changed_attributes }).perform
-
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
-
end
-
end
-
-
1
def rule_present?(event_name, account)
-
return if account.blank?
-
-
current_account_rules(event_name, account).any?
-
end
-
-
1
def current_account_rules(event_name, account)
-
AutomationRule.where(
-
event_name: event_name,
-
account_id: account.id,
-
active: true
-
)
-
end
-
-
1
def performed_by_automation?(event)
-
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
-
end
-
-
1
def ignore_message_created_event?(event)
-
message = event.data[:message]
-
performed_by_automation?(event) || message.activity?
-
end
-
end
-
1
class BaseListener
-
1
include Singleton
-
-
1
def extract_conversation_and_account(event)
-
12
conversation = event.data[:conversation]
-
12
[conversation, conversation.account]
-
end
-
-
1
def extract_notification_and_account(event)
-
notification = event.data[:notification]
-
notification_finder = NotificationFinder.new(notification.user, notification.account)
-
unread_count = notification_finder.unread_count
-
count = notification_finder.count
-
[notification, notification.account, unread_count, count]
-
end
-
-
1
def extract_message_and_account(event)
-
15
message = event.data[:message]
-
15
[message, message.account]
-
end
-
-
1
def extract_contact_and_account(event)
-
9
contact = event.data[:contact]
-
9
[contact, contact.account]
-
end
-
-
1
def extract_inbox_and_account(event)
-
inbox = event.data[:inbox]
-
[inbox, inbox.account]
-
end
-
-
1
def extract_changed_attributes(event)
-
changed_attributes = event.data[:changed_attributes]
-
-
return if changed_attributes.blank?
-
-
changed_attributes.map { |k, v| { k => { previous_value: v[0], current_value: v[1] } } }
-
end
-
end
-
1
class CampaignListener < BaseListener
-
1
def campaign_triggered(event)
-
contact_inbox = event.data[:contact_inbox]
-
campaign_display_id = event.data[:event_info][:campaign_id]
-
custom_attributes = event.data[:event_info][:custom_attributes]
-
-
return if campaign_display_id.blank?
-
-
::Campaigns::CampaignConversationBuilder.new(
-
contact_inbox_id: contact_inbox.id,
-
campaign_display_id: campaign_display_id,
-
conversation_additional_attributes: event.data[:event_info].except(:campaign_id, :custom_attributes),
-
custom_attributes: custom_attributes
-
).perform
-
end
-
end
-
1
class CsatSurveyListener < BaseListener
-
1
def message_updated(event)
-
message = extract_message_and_account(event)[0]
-
return unless message.input_csat?
-
-
CsatSurveys::ResponseBuilder.new(message: message).perform
-
end
-
end
-
1
class HookListener < BaseListener
-
1
def message_created(event)
-
message = extract_message_and_account(event)[0]
-
-
execute_hooks(event, message)
-
end
-
-
1
def message_updated(event)
-
message = extract_message_and_account(event)[0]
-
-
execute_hooks(event, message)
-
end
-
-
1
def contact_created(event)
-
contact = extract_contact_and_account(event)[0]
-
execute_account_hooks(event, contact.account, contact: contact)
-
end
-
-
1
def contact_updated(event)
-
contact = extract_contact_and_account(event)[0]
-
execute_account_hooks(event, contact.account, contact: contact)
-
end
-
-
1
def conversation_created(event)
-
conversation = extract_conversation_and_account(event)[0]
-
execute_account_hooks(event, conversation.account, conversation: conversation)
-
end
-
-
1
def conversation_resolved(event)
-
conversation = extract_conversation_and_account(event)[0]
-
# Only trigger for status changes is resolved
-
return unless conversation.status == 'resolved'
-
-
execute_account_hooks(event, conversation.account, conversation: conversation)
-
end
-
-
1
private
-
-
1
def execute_hooks(event, message)
-
message.account.hooks.each do |hook|
-
# In case of dialogflow, we would have a hook for each inbox.
-
# Which means we will execute the same hook multiple times if the below filter isn't there
-
next if hook.inbox.present? && hook.inbox != message.inbox
-
-
HookJob.perform_later(hook, event.name, message: message)
-
end
-
end
-
-
1
def execute_account_hooks(event, account, event_data = {})
-
account.hooks.account_hooks.find_each do |hook|
-
HookJob.perform_later(hook, event.name, event_data)
-
end
-
end
-
end
-
1
class InstallationWebhookListener < BaseListener
-
1
def account_created(event)
-
payload = account(event).webhook_data.merge(
-
event: __method__.to_s,
-
users: users(event)
-
)
-
deliver_webhook_payloads(payload)
-
end
-
-
1
private
-
-
1
def account(event)
-
event.data[:account]
-
end
-
-
1
def users(event)
-
account(event).administrators.map(&:webhook_data)
-
end
-
-
1
def deliver_webhook_payloads(payload)
-
# Deliver the installation event
-
webhook_url = InstallationConfig.find_by(name: 'INSTALLATION_EVENTS_WEBHOOK_URL')&.value
-
WebhookJob.perform_later(webhook_url, payload) if webhook_url
-
end
-
end
-
1
class NotificationListener < BaseListener
-
1
def conversation_bot_handoff(event)
-
conversation, account = extract_conversation_and_account(event)
-
return if conversation.pending?
-
-
conversation.inbox.members.each do |agent|
-
NotificationBuilder.new(
-
notification_type: 'conversation_creation',
-
user: agent,
-
account: account,
-
primary_actor: conversation
-
).perform
-
end
-
end
-
-
1
def conversation_created(event)
-
conversation, account = extract_conversation_and_account(event)
-
return if conversation.pending?
-
-
conversation.inbox.members.each do |agent|
-
NotificationBuilder.new(
-
notification_type: 'conversation_creation',
-
user: agent,
-
account: account,
-
primary_actor: conversation
-
).perform
-
end
-
end
-
-
1
def assignee_changed(event)
-
conversation, account = extract_conversation_and_account(event)
-
assignee = conversation.assignee
-
-
# NOTE: The issue was that when a team change results in an assignee being set to nil,
-
# the system was still trying to create a notification about the assignment change,
-
# but there was no assignee to notify, causing potential issues in the notification system.
-
# We need to debug this properly, but for now no need to pollute the jobs
-
return if assignee.blank?
-
return if event.data[:notifiable_assignee_change].blank?
-
return if conversation.pending?
-
-
NotificationBuilder.new(
-
notification_type: 'conversation_assignment',
-
user: assignee,
-
account: account,
-
primary_actor: conversation
-
).perform
-
end
-
-
1
def message_created(event)
-
message = extract_message_and_account(event)[0]
-
-
Messages::MentionService.new(message: message).perform
-
Messages::NewMessageNotificationService.new(message: message).perform
-
end
-
end
-
1
class ParticipationListener < BaseListener
-
1
include Events::Types
-
-
1
def assignee_changed(event)
-
conversation, _account = extract_conversation_and_account(event)
-
return if conversation.assignee_id.blank?
-
-
conversation.conversation_participants.find_or_create_by!(user_id: conversation.assignee_id)
-
# We have observed race conditions triggering these errors
-
# example: Assignment happening via automation, while auto assignment is also configured.
-
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
-
Rails.logger.warn "Failed to create conversation participant for account #{conversation.account.id} " \
-
": user #{conversation.assignee_id} : conversation #{conversation.id}"
-
end
-
end
-
1
class ReportingEventListener < BaseListener
-
1
include ReportingEventHelper
-
-
1
def conversation_resolved(event)
-
conversation = extract_conversation_and_account(event)[0]
-
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
-
-
reporting_event = ReportingEvent.new(
-
name: 'conversation_resolved',
-
value: time_to_resolve,
-
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
-
conversation.updated_at),
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
user_id: conversation.assignee_id,
-
conversation_id: conversation.id,
-
event_start_time: conversation.created_at,
-
event_end_time: conversation.updated_at
-
)
-
-
create_bot_resolved_event(conversation, reporting_event)
-
reporting_event.save!
-
end
-
-
1
def first_reply_created(event)
-
message = extract_message_and_account(event)[0]
-
conversation = message.conversation
-
first_response_time = message.created_at.to_i - last_non_human_activity(conversation).to_i
-
-
reporting_event = ReportingEvent.new(
-
name: 'first_response',
-
value: first_response_time,
-
value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation),
-
message.created_at),
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
user_id: message.sender_id,
-
conversation_id: conversation.id,
-
event_start_time: last_non_human_activity(conversation),
-
event_end_time: message.created_at
-
)
-
-
reporting_event.save!
-
end
-
-
1
def reply_created(event)
-
message = extract_message_and_account(event)[0]
-
conversation = message.conversation
-
waiting_since = event.data[:waiting_since]
-
reply_time = message.created_at.to_i - waiting_since.to_i
-
-
reporting_event = ReportingEvent.new(
-
name: 'reply_time',
-
value: reply_time,
-
value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at),
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
user_id: conversation.assignee_id,
-
conversation_id: conversation.id,
-
event_start_time: waiting_since,
-
event_end_time: message.created_at
-
)
-
reporting_event.save!
-
end
-
-
1
def conversation_bot_handoff(event)
-
conversation = extract_conversation_and_account(event)[0]
-
-
# check if a conversation_bot_handoff event exists for this conversation
-
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
-
return if bot_handoff_event.present?
-
-
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
-
-
reporting_event = ReportingEvent.new(
-
name: 'conversation_bot_handoff',
-
value: time_to_handoff,
-
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
user_id: conversation.assignee_id,
-
conversation_id: conversation.id,
-
event_start_time: conversation.created_at,
-
event_end_time: conversation.updated_at
-
)
-
reporting_event.save!
-
end
-
-
1
private
-
-
1
def create_bot_resolved_event(conversation, reporting_event)
-
return unless conversation.inbox.active_bot?
-
# We don't want to create a bot_resolved event if there is user interaction on the conversation
-
return if conversation.messages.exists?(message_type: :outgoing, sender_type: 'User')
-
-
bot_resolved_event = reporting_event.dup
-
bot_resolved_event.name = 'conversation_bot_resolved'
-
bot_resolved_event.save!
-
end
-
end
-
1
class WebhookListener < BaseListener
-
1
def conversation_status_changed(event)
-
conversation = extract_conversation_and_account(event)[0]
-
changed_attributes = extract_changed_attributes(event)
-
inbox = conversation.inbox
-
payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
-
deliver_webhook_payloads(payload, inbox)
-
end
-
-
1
def conversation_updated(event)
-
conversation = extract_conversation_and_account(event)[0]
-
changed_attributes = extract_changed_attributes(event)
-
inbox = conversation.inbox
-
payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
-
deliver_webhook_payloads(payload, inbox)
-
end
-
-
1
def conversation_created(event)
-
conversation = extract_conversation_and_account(event)[0]
-
inbox = conversation.inbox
-
payload = conversation.webhook_data.merge(event: __method__.to_s)
-
deliver_webhook_payloads(payload, inbox)
-
end
-
-
1
def message_created(event)
-
message = extract_message_and_account(event)[0]
-
inbox = message.inbox
-
-
return unless message.webhook_sendable?
-
-
payload = message.webhook_data.merge(event: __method__.to_s)
-
deliver_webhook_payloads(payload, inbox)
-
end
-
-
1
def message_updated(event)
-
message = extract_message_and_account(event)[0]
-
inbox = message.inbox
-
-
return unless message.webhook_sendable?
-
-
payload = message.webhook_data.merge(event: __method__.to_s)
-
deliver_webhook_payloads(payload, inbox)
-
end
-
-
1
def webwidget_triggered(event)
-
contact_inbox = event.data[:contact_inbox]
-
inbox = contact_inbox.inbox
-
-
payload = contact_inbox.webhook_data.merge(event: __method__.to_s)
-
payload[:event_info] = event.data[:event_info]
-
deliver_webhook_payloads(payload, inbox)
-
end
-
-
1
def contact_created(event)
-
contact, account = extract_contact_and_account(event)
-
payload = contact.webhook_data.merge(event: __method__.to_s)
-
deliver_account_webhooks(payload, account)
-
end
-
-
1
def contact_updated(event)
-
contact, account = extract_contact_and_account(event)
-
changed_attributes = extract_changed_attributes(event)
-
return if changed_attributes.blank?
-
-
payload = contact.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
-
deliver_account_webhooks(payload, account)
-
end
-
-
1
def inbox_created(event)
-
inbox, account = extract_inbox_and_account(event)
-
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
-
payload = inbox_webhook_data.merge(event: __method__.to_s)
-
deliver_account_webhooks(payload, account)
-
end
-
-
1
def inbox_updated(event)
-
inbox, account = extract_inbox_and_account(event)
-
changed_attributes = extract_changed_attributes(event)
-
return if changed_attributes.blank?
-
-
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
-
payload = inbox_webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
-
deliver_account_webhooks(payload, account)
-
end
-
-
1
private
-
-
1
def deliver_account_webhooks(payload, account)
-
account.webhooks.account_type.each do |webhook|
-
next unless webhook.subscriptions.include?(payload[:event])
-
-
WebhookJob.perform_later(webhook.url, payload)
-
end
-
end
-
-
1
def deliver_api_inbox_webhooks(payload, inbox)
-
return unless inbox.channel_type == 'Channel::Api'
-
return if inbox.channel.webhook_url.blank?
-
-
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook)
-
end
-
-
1
def deliver_webhook_payloads(payload, inbox)
-
deliver_account_webhooks(payload, inbox.account)
-
deliver_api_inbox_webhooks(payload, inbox)
-
end
-
end
-
1
class ApplicationMailbox < ActionMailbox::Base
-
1
include MailboxHelper
-
-
# Last part is the regex for the UUID
-
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
-
1
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
-
1
CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
-
-
# routes as a reply to existing conversations
-
1
routing(
-
->(inbound_mail) { valid_to_address?(inbound_mail) && (reply_uuid_mail?(inbound_mail) || in_reply_to_mail?(inbound_mail)) } => :reply
-
)
-
-
# routes as a new conversation in email channel
-
1
routing(
-
->(inbound_mail) { valid_to_address?(inbound_mail) && EmailChannelFinder.new(inbound_mail.mail).perform.present? } => :support
-
)
-
-
# catchall
-
1
routing(all: :default)
-
-
1
class << self
-
# checks if follow this pattern then send it to reply_mailbox
-
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
-
1
def in_reply_to_mail?(inbound_mail)
-
in_reply_to = inbound_mail.mail.in_reply_to
-
-
in_reply_to.present? && (
-
in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
-
)
-
end
-
-
1
def in_reply_to_matches?(in_reply_to)
-
Array.wrap(in_reply_to).any? { _1.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
-
end
-
-
# checks if follow this pattern send it to reply_mailbox
-
# reply+<conversation-uuid>@<mailer-domain.com>
-
1
def reply_uuid_mail?(inbound_mail)
-
inbound_mail.mail.to&.any? do |email|
-
conversation_uuid = email.split('@')[0]
-
conversation_uuid.match?(REPLY_EMAIL_UUID_PATTERN)
-
end
-
end
-
-
# if mail.to returns a string, then it is a malformed `to` header
-
# valid `to` header will be of type Mail::AddressContainer
-
# validate if the to address is of type string
-
1
def valid_to_address?(inbound_mail)
-
to_address_class = inbound_mail.mail.to&.class
-
return true if to_address_class == Mail::AddressContainer
-
-
Rails.logger.error "Email to address header is malformed `#{inbound_mail.mail.to}`"
-
false
-
end
-
end
-
end
-
class DefaultMailbox < ApplicationMailbox
-
def process; end
-
end
-
class Imap::ImapMailbox
-
include MailboxHelper
-
include IncomingEmailValidityHelper
-
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
-
-
def process(mail, channel)
-
@inbound_mail = mail
-
@channel = channel
-
load_account
-
load_inbox
-
decorate_mail
-
-
Rails.logger.info("Processing Email from: #{@processed_mail.original_sender} : inbox #{@inbox.id} : message_id #{@processed_mail.message_id}")
-
-
# Skip processing email if it belongs to any of the edge cases
-
return unless incoming_email_from_valid_email?
-
-
ActiveRecord::Base.transaction do
-
find_or_create_contact
-
find_or_create_conversation
-
create_message
-
add_attachments_to_message
-
end
-
end
-
-
private
-
-
def load_account
-
@account = @channel.account
-
end
-
-
def load_inbox
-
@inbox = @channel.inbox
-
end
-
-
def decorate_mail
-
@processed_mail = MailPresenter.new(@inbound_mail, @account)
-
end
-
-
def find_conversation_by_in_reply_to
-
return if in_reply_to.blank?
-
-
message = @inbox.messages.find_by(source_id: in_reply_to)
-
if message.nil?
-
@inbox.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
-
else
-
@inbox.conversations.find(message.conversation_id)
-
end
-
end
-
-
def find_conversation_by_reference_ids
-
return if @inbound_mail.references.blank? && in_reply_to.present?
-
-
message = find_message_by_references
-
-
return if message.nil?
-
-
@inbox.conversations.find(message.conversation_id)
-
end
-
-
def in_reply_to
-
@processed_mail.in_reply_to
-
end
-
-
def find_message_by_references
-
message_to_return = nil
-
-
references = Array.wrap(@inbound_mail.references)
-
-
references.each do |message_id|
-
message = @inbox.messages.find_by(source_id: message_id)
-
message_to_return = message if message.present?
-
end
-
message_to_return
-
end
-
-
def find_or_create_conversation
-
@conversation = find_conversation_by_in_reply_to || find_conversation_by_reference_ids || ::Conversation.create!(
-
{
-
account_id: @account.id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: {
-
source: 'email',
-
in_reply_to: in_reply_to,
-
mail_subject: @processed_mail.subject,
-
initiated_at: {
-
timestamp: Time.now.utc
-
}
-
}
-
}
-
)
-
end
-
-
def find_or_create_contact
-
@contact = @inbox.contacts.from_email(@processed_mail.original_sender)
-
if @contact.present?
-
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
-
else
-
create_contact
-
end
-
end
-
-
def identify_contact_name
-
processed_mail.sender_name || processed_mail.from.first.split('@').first
-
end
-
end
-
module IncomingEmailValidityHelper
-
private
-
-
def incoming_email_from_valid_email?
-
return false unless valid_external_email_for_active_account?
-
-
# we skip processing auto reply emails like delivery status notifications
-
# out of office replies, etc.
-
return false if auto_reply_email?
-
-
# return if email doesn't have a valid sender
-
# This can happen in cases like bounce emails for invalid contact email address
-
# TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
-
# The returned value could be "\"\"" for some email clients
-
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
-
-
true
-
end
-
-
def valid_external_email_for_active_account?
-
return false unless @account.active?
-
return false if @processed_mail.notification_email_from_chatwoot?
-
-
true
-
end
-
-
def auto_reply_email?
-
if @processed_mail.auto_reply?
-
Rails.logger.info "is_auto_reply? : #{processed_mail.auto_reply?}"
-
true
-
else
-
false
-
end
-
end
-
end
-
1
module MailboxHelper
-
1
private
-
-
1
def create_message
-
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
-
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
-
-
@message = @conversation.messages.create!(
-
account_id: @conversation.account_id,
-
sender: @conversation.contact,
-
content: mail_content&.truncate(150_000),
-
inbox_id: @conversation.inbox_id,
-
message_type: 'incoming',
-
content_type: 'incoming_email',
-
source_id: processed_mail.message_id,
-
content_attributes: {
-
email: processed_mail.serialized_data,
-
cc_email: processed_mail.cc,
-
bcc_email: processed_mail.bcc
-
}
-
)
-
end
-
-
1
def add_attachments_to_message
-
return if @message.blank?
-
-
# ensure we don't add more than the permitted number of attachments
-
all_attachments = processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS)
-
grouped_attachments = group_attachments(all_attachments)
-
-
process_inline_attachments(grouped_attachments[:inline]) if grouped_attachments[:inline].present?
-
process_regular_attachments(grouped_attachments[:regular]) if grouped_attachments[:regular].present?
-
-
@message.save!
-
end
-
-
1
def group_attachments(attachments)
-
# If the email lacks a text body or if inline attachments aren't images,
-
# treat them as standard attachments for processing.
-
inline_attachments = attachments.select do |attachment|
-
mail_content.present? && attachment[:original].inline? && attachment[:original].content_type.to_s.start_with?('image/')
-
end
-
-
regular_attachments = attachments - inline_attachments
-
{ inline: inline_attachments, regular: regular_attachments }
-
end
-
-
1
def process_regular_attachments(attachments)
-
Rails.logger.info "[MailboxHelper] Processing regular attachments for message with ID: #{processed_mail.message_id}"
-
attachments.each do |mail_attachment|
-
attachment = @message.attachments.new(
-
account_id: @conversation.account_id,
-
file_type: 'file'
-
)
-
attachment.file.attach(mail_attachment[:blob])
-
end
-
end
-
-
1
def process_inline_attachments(attachments)
-
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
-
-
# create an instance variable here, the `embed_inline_image_source`
-
# updates them directly. And then the value is eventaully used to update the message content
-
@html_content = processed_mail.serialized_data[:html_content][:full]
-
@text_content = processed_mail.serialized_data[:text_content][:reply]
-
-
attachments.each do |mail_attachment|
-
embed_inline_image_source(mail_attachment)
-
end
-
-
# update the message content with the updated html and text content
-
@message.content_attributes[:email][:html_content][:full] = @html_content
-
@message.content_attributes[:email][:text_content][:full] = @text_content
-
end
-
-
1
def embed_inline_image_source(mail_attachment)
-
if @html_content.present?
-
upload_inline_image(mail_attachment)
-
elsif @text_content.present?
-
embed_plain_text_email_with_inline_image(mail_attachment)
-
end
-
end
-
-
1
def upload_inline_image(mail_attachment)
-
content_id = mail_attachment[:original].cid
-
-
@html_content = @html_content.gsub("cid:#{content_id}", inline_image_url(mail_attachment[:blob]).to_s)
-
end
-
-
1
def embed_plain_text_email_with_inline_image(mail_attachment)
-
attachment_name = mail_attachment[:original].filename
-
img_tag = "<img src=\"#{inline_image_url(mail_attachment[:blob])}\" alt=\"#{attachment_name}\">"
-
-
tag_to_replace = "[image: #{attachment_name}]"
-
-
if @text_content.include?(tag_to_replace)
-
@text_content = @text_content.gsub(tag_to_replace, img_tag)
-
else
-
@text_content += "\n\n#{img_tag}"
-
end
-
end
-
-
1
def inline_image_url(blob)
-
Rails.application.routes.url_helpers.url_for(blob)
-
end
-
-
1
def create_contact
-
@contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: processed_mail.original_sender,
-
inbox: @inbox,
-
contact_attributes: {
-
name: identify_contact_name,
-
email: processed_mail.original_sender,
-
additional_attributes: { source_id: "email:#{processed_mail.message_id}" }
-
}
-
).perform
-
-
@contact = @contact_inbox.contact
-
Rails.logger.info "[MailboxHelper] Contact created with ID: #{@contact.id} for inbox with ID: #{@inbox.id}"
-
end
-
-
1
def mail_content
-
if processed_mail.text_content.present?
-
processed_mail.text_content[:reply]
-
elsif processed_mail.html_content.present?
-
processed_mail.html_content[:reply]
-
end
-
end
-
end
-
class ReplyMailbox < ApplicationMailbox
-
attr_accessor :conversation_uuid, :processed_mail
-
-
# Last part is the regex for the UUID
-
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
-
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
-
-
before_processing :conversation_uuid_from_to_address,
-
:find_relative_conversation
-
-
def process
-
return if @conversation.blank?
-
-
decorate_mail
-
create_message
-
add_attachments_to_message
-
end
-
-
private
-
-
def find_relative_conversation
-
if @conversation_uuid
-
find_conversation_with_uuid
-
elsif mail.in_reply_to.present?
-
find_conversation_with_in_reply_to
-
end
-
end
-
-
def conversation_uuid_from_to_address
-
@mail = MailPresenter.new(mail)
-
-
return if @mail.mail_receiver.blank?
-
-
@mail.mail_receiver.each do |email|
-
username = email.split('@')[0]
-
match_result = username.match(ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN)
-
if match_result
-
@conversation_uuid = match_result.captures
-
break
-
end
-
end
-
@conversation_uuid
-
end
-
-
# find conversation uuid from below pattern
-
# reply+<conversation-uuid>@<mailer-domain.com>
-
def find_conversation_with_uuid
-
@conversation = Conversation.find_by(uuid: conversation_uuid)
-
validate_resource @conversation
-
end
-
-
def find_conversation_by_uuid(match_result)
-
@conversation_uuid = match_result.captures[0]
-
-
find_conversation_with_uuid
-
end
-
-
def find_conversation_by_message_id(in_reply_to)
-
@message = Message.find_by(source_id: in_reply_to)
-
@conversation = @message.conversation if @message.present?
-
@conversation_uuid = @conversation.uuid if @conversation.present?
-
end
-
-
# find conversation uuid from below pattern
-
# <conversation/#{@conversation.uuid}/messages/#{@messages&.last&.id}@#{@account.inbound_email_domain}>
-
def find_conversation_with_in_reply_to
-
match_result = nil
-
in_reply_to_addresses = mail.in_reply_to
-
in_reply_to_addresses = [in_reply_to_addresses] if in_reply_to_addresses.is_a?(String)
-
in_reply_to_addresses.each do |in_reply_to|
-
match_result = in_reply_to.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
-
break if match_result
-
end
-
find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
-
end
-
-
def find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
-
find_conversation_by_uuid(match_result) if match_result
-
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
-
end
-
-
def validate_resource(resource)
-
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
-
-
resource
-
end
-
-
def decorate_mail
-
@processed_mail = MailPresenter.new(mail, @conversation.account)
-
end
-
end
-
class SupportMailbox < ApplicationMailbox
-
include IncomingEmailValidityHelper
-
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
-
-
before_processing :find_channel,
-
:load_account,
-
:load_inbox,
-
:decorate_mail
-
-
def process
-
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
-
-
# Skip processing email if it belongs to any of the edge cases
-
return unless incoming_email_from_valid_email?
-
-
ActiveRecord::Base.transaction do
-
find_or_create_contact
-
find_or_create_conversation
-
create_message
-
add_attachments_to_message
-
end
-
end
-
-
private
-
-
def find_channel
-
find_channel_with_to_mail if @channel.blank?
-
-
raise 'Email channel/inbox not found' if @channel.nil?
-
-
@channel
-
end
-
-
def find_channel_with_to_mail
-
@channel = EmailChannelFinder.new(mail).perform
-
end
-
-
def load_account
-
@account = @channel.account
-
end
-
-
def load_inbox
-
@inbox = @channel.inbox
-
end
-
-
def decorate_mail
-
@processed_mail = MailPresenter.new(mail, @account)
-
end
-
-
def find_conversation_by_in_reply_to
-
return if in_reply_to.blank?
-
-
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
-
end
-
-
def in_reply_to
-
mail['In-Reply-To'].try(:value)
-
end
-
-
def original_sender_email
-
@processed_mail.original_sender&.downcase
-
end
-
-
def find_or_create_conversation
-
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
-
account_id: @account.id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: {
-
in_reply_to: in_reply_to,
-
source: 'email',
-
mail_subject: @processed_mail.subject,
-
initiated_at: {
-
timestamp: Time.now.utc
-
}
-
}
-
})
-
end
-
-
def find_or_create_contact
-
@contact = @inbox.contacts.from_email(original_sender_email)
-
if @contact.present?
-
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
-
else
-
create_contact
-
end
-
end
-
-
def identify_contact_name
-
processed_mail.sender_name || processed_mail.from.first.split('@').first
-
end
-
end
-
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
-
def account_deletion(account, reason = 'manual_deletion')
-
subject = 'Your account has been marked for deletion'
-
action_url = settings_url('general')
-
meta = {
-
'account_name' => account.name,
-
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
-
'reason' => reason
-
}
-
-
send_notification(subject, action_url: action_url, meta: meta)
-
end
-
-
def contact_import_complete(resource)
-
subject = 'Contact Import Completed'
-
-
action_url = if resource.failed_records.attached?
-
Rails.application.routes.url_helpers.rails_blob_url(resource.failed_records)
-
else
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
-
end
-
-
meta = {
-
'failed_contacts' => resource.total_records - resource.processed_records,
-
'imported_contacts' => resource.processed_records
-
}
-
-
send_notification(subject, action_url: action_url, meta: meta)
-
end
-
-
def contact_import_failed
-
subject = 'Contact Import Failed'
-
send_notification(subject)
-
end
-
-
def contact_export_complete(file_url, email_to)
-
subject = "Your contact's export file is available to download."
-
send_notification(subject, to: email_to, action_url: file_url)
-
end
-
-
def automation_rule_disabled(rule)
-
subject = 'Automation rule disabled due to validation errors.'
-
action_url = settings_url('automation/list')
-
meta = { 'rule_name' => rule.name }
-
-
send_notification(subject, action_url: action_url, meta: meta)
-
end
-
end
-
class AdministratorNotifications::BaseMailer < ApplicationMailer
-
# Common method to check SMTP configuration and send mail with liquid
-
def send_notification(subject, to: nil, action_url: nil, meta: {})
-
return unless smtp_config_set_or_development?
-
-
@action_url = action_url
-
@meta = meta || {}
-
-
send_mail_with_liquid(to: to || admin_emails, subject: subject) and return
-
end
-
-
# Helper method to generate inbox URL
-
def inbox_url(inbox)
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
-
end
-
-
# Helper method to generate settings URL
-
def settings_url(section)
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/#{section}"
-
end
-
-
private
-
-
def admin_emails
-
Current.account.administrators.pluck(:email)
-
end
-
-
def liquid_locals
-
super.merge({ meta: @meta })
-
end
-
end
-
class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNotifications::BaseMailer
-
def facebook_disconnect(inbox)
-
subject = 'Your Facebook page connection has expired'
-
send_notification(subject, action_url: inbox_url(inbox))
-
end
-
-
def instagram_disconnect(inbox)
-
subject = 'Your Instagram connection has expired'
-
send_notification(subject, action_url: inbox_url(inbox))
-
end
-
-
def whatsapp_disconnect(inbox)
-
subject = 'Your Whatsapp connection has expired'
-
send_notification(subject, action_url: inbox_url(inbox))
-
end
-
-
def email_disconnect(inbox)
-
subject = 'Your email inbox has been disconnected. Please update the credentials for SMTP/IMAP'
-
send_notification(subject, action_url: inbox_url(inbox))
-
end
-
end
-
class AdministratorNotifications::IntegrationsNotificationMailer < AdministratorNotifications::BaseMailer
-
def slack_disconnect
-
subject = 'Your Slack integration has expired'
-
action_url = settings_url('integrations/slack')
-
send_notification(subject, action_url: action_url)
-
end
-
-
def dialogflow_disconnect
-
subject = 'Your Dialogflow integration was disconnected'
-
send_notification(subject)
-
end
-
end
-
class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
-
def conversation_creation(conversation, agent, _user)
-
return unless smtp_config_set_or_development?
-
-
@agent = agent
-
@conversation = conversation
-
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}."
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
send_mail_with_liquid(to: @agent.email, subject: subject) and return
-
end
-
-
def conversation_assignment(conversation, agent, _user)
-
return unless smtp_config_set_or_development?
-
-
@agent = agent
-
@conversation = conversation
-
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been assigned to you."
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
send_mail_with_liquid(to: @agent.email, subject: subject) and return
-
end
-
-
def conversation_mention(conversation, agent, message)
-
return unless smtp_config_set_or_development?
-
-
@agent = agent
-
@conversation = conversation
-
@message = message
-
subject = "#{@agent.available_name}, You have been mentioned in conversation [ID - #{@conversation.display_id}]"
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
send_mail_with_liquid(to: @agent.email, subject: subject) and return
-
end
-
-
def assigned_conversation_new_message(conversation, agent, message)
-
return unless smtp_config_set_or_development?
-
# Don't spam with email notifications if agent is online
-
return if ::OnlineStatusTracker.get_presence(message.account_id, 'User', agent.id)
-
-
@agent = agent
-
@conversation = conversation
-
subject = "#{@agent.available_name}, New message in your assigned conversation [ID - #{@conversation.display_id}]."
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
send_mail_with_liquid(to: @agent.email, subject: subject) and return
-
end
-
-
def participating_conversation_new_message(conversation, agent, message)
-
return unless smtp_config_set_or_development?
-
# Don't spam with email notifications if agent is online
-
return if ::OnlineStatusTracker.get_presence(message.account_id, 'User', agent.id)
-
-
@agent = agent
-
@conversation = conversation
-
subject = "#{@agent.available_name}, New message in your participating conversation [ID - #{@conversation.display_id}]."
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
send_mail_with_liquid(to: @agent.email, subject: subject) and return
-
end
-
-
private
-
-
def liquid_droppables
-
super.merge({
-
user: @agent,
-
conversation: @conversation,
-
inbox: @conversation.inbox,
-
message: @message
-
})
-
end
-
end
-
-
AgentNotifications::ConversationNotificationsMailer.prepend_mod_with('AgentNotifications::ConversationNotificationsMailer')
-
class ApplicationMailer < ActionMailer::Base
-
include ActionView::Helpers::SanitizeHelper
-
-
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
-
before_action { ensure_current_account(params.try(:[], :account)) }
-
around_action :switch_locale
-
layout 'mailer/base'
-
# Fetch template from Database if available
-
# Order: Account Specific > Installation Specific > Fallback to file
-
prepend_view_path ::EmailTemplate.resolver
-
append_view_path Rails.root.join('app/views/mailers')
-
helper :frontend_urls
-
helper do
-
def global_config
-
@global_config ||= GlobalConfig.get('BRAND_NAME', 'BRAND_URL')
-
end
-
end
-
-
rescue_from(*ExceptionList::SMTP_EXCEPTIONS, with: :handle_smtp_exceptions)
-
-
def smtp_config_set_or_development?
-
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
-
end
-
-
private
-
-
def handle_smtp_exceptions(message)
-
Rails.logger.warn 'Failed to send Email'
-
Rails.logger.error "Exception: #{message}"
-
end
-
-
def send_mail_with_liquid(*args)
-
Rails.logger.info "Email sent to #{args[0][:to]} with subject #{args[0][:subject]}"
-
mail(*args) do |format|
-
# explored sending a multipart email containing both text type and html
-
# parsing the html with nokogiri will remove the links as well
-
# might also remove tags like b,li etc. so lets rethink about this later
-
# format.text { Nokogiri::HTML(render(layout: false)).text }
-
format.html { render }
-
end
-
end
-
-
def liquid_droppables
-
# Merge additional objects into this in your mailer
-
# liquid template handler converts these objects into drop objects
-
{
-
account: Current.account,
-
user: @agent,
-
conversation: @conversation,
-
inbox: @conversation&.inbox
-
}
-
end
-
-
def liquid_locals
-
# expose variables you want to be exposed in liquid
-
locals = {
-
global_config: GlobalConfig.get('BRAND_NAME', 'BRAND_URL'),
-
action_url: @action_url
-
}
-
-
locals.merge({ attachment_url: @attachment_url }) if @attachment_url
-
locals.merge({ failed_contacts: @failed_contacts, imported_contacts: @imported_contacts })
-
locals
-
end
-
-
def locale_from_account(account)
-
return unless account
-
-
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
-
end
-
-
def ensure_current_account(account)
-
Current.reset
-
Current.account = account if account.present?
-
end
-
-
def switch_locale(&)
-
locale ||= locale_from_account(Current.account)
-
locale ||= I18n.default_locale
-
# ensure locale won't bleed into other requests
-
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
-
I18n.with_locale(locale, &)
-
end
-
end
-
class ConversationReplyMailer < ApplicationMailer
-
# We needs to expose large attachments to the view as links
-
# Small attachments are linked as mail attachments directly
-
attr_reader :large_attachments
-
-
include ConversationReplyMailerHelper
-
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
-
layout :choose_layout
-
-
def reply_with_summary(conversation, last_queued_id)
-
return unless smtp_config_set_or_development?
-
-
init_conversation_attributes(conversation)
-
return if conversation_already_viewed?
-
-
recap_messages = @conversation.messages.chat.where('id < ?', last_queued_id).last(10)
-
new_messages = @conversation.messages.chat.where('id >= ?', last_queued_id)
-
@messages = recap_messages + new_messages
-
@messages = @messages.select(&:email_reply_summarizable?)
-
prepare_mail(true)
-
end
-
-
def reply_without_summary(conversation, last_queued_id)
-
return unless smtp_config_set_or_development?
-
-
init_conversation_attributes(conversation)
-
return if conversation_already_viewed?
-
-
@messages = @conversation.messages.chat.where(message_type: [:outgoing, :template]).where('id >= ?', last_queued_id)
-
@messages = @messages.reject { |m| m.template? && !m.input_csat? }
-
return false if @messages.count.zero?
-
-
prepare_mail(false)
-
end
-
-
def email_reply(message)
-
return unless smtp_config_set_or_development?
-
-
init_conversation_attributes(message.conversation)
-
@message = message
-
reply_mail_object = prepare_mail(true)
-
message.update(source_id: reply_mail_object.message_id)
-
end
-
-
def conversation_transcript(conversation, to_email)
-
return unless smtp_config_set_or_development?
-
-
init_conversation_attributes(conversation)
-
-
@messages = @conversation.messages.chat.select(&:conversation_transcriptable?)
-
-
Rails.logger.info("Email sent from #{from_email_with_name} \
-
to #{to_email} with subject #{@conversation.display_id} \
-
#{I18n.t('conversations.reply.transcript_subject')} ")
-
mail({
-
to: to_email,
-
from: from_email_with_name,
-
subject: "[##{@conversation.display_id}] #{I18n.t('conversations.reply.transcript_subject')}"
-
})
-
end
-
-
private
-
-
def init_conversation_attributes(conversation)
-
@conversation = conversation
-
@account = @conversation.account
-
@contact = @conversation.contact
-
@agent = @conversation.assignee
-
@inbox = @conversation.inbox
-
@channel = @inbox.channel
-
end
-
-
def should_use_conversation_email_address?
-
@inbox.inbox_type == 'Email' || inbound_email_enabled?
-
end
-
-
def conversation_already_viewed?
-
# whether contact already saw the message on widget
-
return unless @conversation.contact_last_seen_at
-
return unless last_outgoing_message&.created_at
-
-
@conversation.contact_last_seen_at > last_outgoing_message&.created_at
-
end
-
-
def last_outgoing_message
-
@conversation.messages.chat.where.not(message_type: :incoming)&.last
-
end
-
-
def sender_name(sender_email)
-
if @inbox.friendly?
-
I18n.t('conversations.reply.email.header.friendly_name', sender_name: custom_sender_name, business_name: business_name,
-
from_email: sender_email)
-
else
-
I18n.t('conversations.reply.email.header.professional_name', business_name: business_name, from_email: sender_email)
-
end
-
end
-
-
def current_message
-
@message || @conversation.messages.outgoing.last
-
end
-
-
def custom_sender_name
-
current_message&.sender&.available_name || @agent&.available_name || 'Notifications'
-
end
-
-
def business_name
-
@inbox.business_name || @inbox.name
-
end
-
-
def from_email
-
should_use_conversation_email_address? ? parse_email(@account.support_email) : parse_email(inbox_from_email_address)
-
end
-
-
def mail_subject
-
subject = @conversation.additional_attributes['mail_subject']
-
return "[##{@conversation.display_id}] #{I18n.t('conversations.reply.email_subject')}" if subject.nil?
-
-
chat_count = @conversation.messages.chat.count
-
if chat_count > 1
-
"Re: #{subject}"
-
else
-
subject
-
end
-
end
-
-
def reply_email
-
if should_use_conversation_email_address?
-
sender_name("reply+#{@conversation.uuid}@#{@account.inbound_email_domain}")
-
else
-
@inbox.email_address || @agent&.email
-
end
-
end
-
-
def from_email_with_name
-
sender_name(from_email)
-
end
-
-
def channel_email_with_name
-
sender_name(@channel.email)
-
end
-
-
def parse_email(email_string)
-
Mail::Address.new(email_string).address
-
end
-
-
def inbox_from_email_address
-
return @inbox.email_address if @inbox.email_address
-
-
@account.support_email
-
end
-
-
def custom_message_id
-
last_message = @message || @messages&.last
-
-
"<conversation/#{@conversation.uuid}/messages/#{last_message&.id}@#{channel_email_domain}>"
-
end
-
-
def in_reply_to_email
-
conversation_reply_email_id || "<account/#{@account.id}/conversation/#{@conversation.uuid}@#{channel_email_domain}>"
-
end
-
-
def conversation_reply_email_id
-
content_attributes = @conversation.messages.incoming.last&.content_attributes
-
-
if content_attributes && content_attributes['email'] && content_attributes['email']['message_id']
-
return "<#{content_attributes['email']['message_id']}>"
-
end
-
-
nil
-
end
-
-
def cc_bcc_emails
-
content_attributes = @conversation.messages.outgoing.last&.content_attributes
-
-
return [] unless content_attributes
-
return [] unless content_attributes[:cc_emails] || content_attributes[:bcc_emails]
-
-
[content_attributes[:cc_emails], content_attributes[:bcc_emails]]
-
end
-
-
def to_emails_from_content_attributes
-
content_attributes = @conversation.messages.outgoing.last&.content_attributes
-
-
return [] unless content_attributes
-
return [] unless content_attributes[:to_emails]
-
-
content_attributes[:to_emails]
-
end
-
-
def to_emails
-
# if there is no to_emails from content_attributes, send it to @contact&.email
-
to_emails_from_content_attributes.presence || [@contact&.email]
-
end
-
-
def inbound_email_enabled?
-
@inbound_email_enabled ||= @account.feature_enabled?('inbound_emails') && @account.inbound_email_domain
-
.present? && @account.support_email.present?
-
end
-
-
def choose_layout
-
return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
-
-
'mailer/base'
-
end
-
end
-
module ConversationReplyMailerHelper
-
def prepare_mail(cc_bcc_enabled)
-
@options = {
-
to: to_emails,
-
from: email_from,
-
reply_to: email_reply_to,
-
subject: mail_subject,
-
message_id: custom_message_id,
-
in_reply_to: in_reply_to_email
-
}
-
-
if cc_bcc_enabled
-
@options[:cc] = cc_bcc_emails[0]
-
@options[:bcc] = cc_bcc_emails[1]
-
end
-
ms_smtp_settings
-
google_smtp_settings
-
set_delivery_method
-
-
# Email type detection logic:
-
# - email_reply: Sets @message with a single message
-
# - Other actions: Set @messages with a collection of messages
-
#
-
# So this check implicitly determines we're handling an email_reply
-
# and not one of the other email types (summary, transcript, etc.)
-
process_attachments_as_files_for_email_reply if @message&.attachments.present?
-
mail(@options)
-
end
-
-
def process_attachments_as_files_for_email_reply
-
# Attachment processing for direct email replies (when replying to a single message)
-
#
-
# How attachments are handled:
-
# 1. Total file size (<20MB): Added directly to the email as proper attachments
-
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
-
-
@options[:attachments] = []
-
@large_attachments = []
-
current_total_size = 0
-
-
@message.attachments.each do |attachment|
-
raw_data = attachment.file.download
-
attachment_name = attachment.file.filename.to_s
-
file_size = raw_data.bytesize
-
-
# Attach files directly until we hit 20MB total
-
# After reaching 20MB, send remaining files as links
-
if current_total_size + file_size <= 20.megabytes
-
mail.attachments[attachment_name] = raw_data
-
@options[:attachments] << { name: attachment_name }
-
current_total_size += file_size
-
else
-
@large_attachments << attachment
-
end
-
end
-
end
-
-
private
-
-
def google_smtp_settings
-
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.google?
-
-
smtp_settings = base_smtp_settings('smtp.gmail.com')
-
-
@options[:delivery_method] = :smtp
-
@options[:delivery_method_options] = smtp_settings
-
end
-
-
def ms_smtp_settings
-
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.microsoft?
-
-
smtp_settings = base_smtp_settings('smtp.office365.com')
-
-
@options[:delivery_method] = :smtp
-
@options[:delivery_method_options] = smtp_settings
-
end
-
-
def base_smtp_settings(domain)
-
{
-
address: domain,
-
port: 587,
-
user_name: @channel.imap_login,
-
password: @channel.provider_config['access_token'],
-
domain: domain,
-
tls: false,
-
enable_starttls_auto: true,
-
openssl_verify_mode: 'none',
-
open_timeout: 15,
-
read_timeout: 15,
-
authentication: 'xoauth2'
-
}
-
end
-
-
def set_delivery_method
-
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
-
-
smtp_settings = {
-
address: @channel.smtp_address,
-
port: @channel.smtp_port,
-
user_name: @channel.smtp_login,
-
password: @channel.smtp_password,
-
domain: @channel.smtp_domain,
-
tls: @channel.smtp_enable_ssl_tls,
-
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
-
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
-
authentication: @channel.smtp_authentication
-
}
-
-
@options[:delivery_method] = :smtp
-
@options[:delivery_method_options] = smtp_settings
-
end
-
-
def email_smtp_enabled
-
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
-
end
-
-
def email_imap_enabled
-
@inbox.inbox_type == 'Email' && @channel.imap_enabled
-
end
-
-
def email_oauth_enabled
-
@inbox.inbox_type == 'Email' && (@channel.microsoft? || @channel.google?)
-
end
-
-
def email_from
-
email_oauth_enabled || email_smtp_enabled ? channel_email_with_name : from_email_with_name
-
end
-
-
def email_reply_to
-
email_imap_enabled ? @channel.email : reply_email
-
end
-
-
# Use channel email domain in case of account email domain is not set for custom message_id and in_reply_to
-
def channel_email_domain
-
return @account.inbound_email_domain if @account.inbound_email_domain.present?
-
-
email = @inbox.channel.try(:email)
-
email.present? ? email.split('@').last : raise(StandardError, 'Channel email domain not present.')
-
end
-
end
-
class TeamNotifications::AutomationNotificationMailer < ApplicationMailer
-
def conversation_creation(conversation, team, message)
-
return unless smtp_config_set_or_development?
-
-
@agents = team.team_members
-
@conversation = conversation
-
@custom_message = message
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
-
send_an_email_to_team
-
end
-
-
private
-
-
def send_an_email_to_team
-
subject = 'This email has been sent via automation rule actions.'
-
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
-
@agent_emails = @agents.collect(&:user).pluck(:email)
-
send_mail_with_liquid(to: @agent_emails, subject: subject) and return
-
end
-
-
def liquid_droppables
-
super.merge!({
-
conversation: @conversation,
-
inbox: @conversation.inbox
-
})
-
end
-
-
def liquid_locals
-
super.merge!({
-
custom_message: @custom_message
-
})
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: access_tokens
-
#
-
# id :bigint not null, primary key
-
# owner_type :string
-
# token :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# owner_id :bigint
-
#
-
# Indexes
-
#
-
# index_access_tokens_on_owner_type_and_owner_id (owner_type,owner_id)
-
# index_access_tokens_on_token (token) UNIQUE
-
#
-
-
1
class AccessToken < ApplicationRecord
-
1
has_secure_token :token
-
1
belongs_to :owner, polymorphic: true
-
end
-
# == Schema Information
-
#
-
# Table name: accounts
-
#
-
# id :integer not null, primary key
-
# auto_resolve_duration :integer
-
# custom_attributes :jsonb
-
# domain :string(100)
-
# feature_flags :bigint default(0), not null
-
# internal_attributes :jsonb not null
-
# limits :jsonb
-
# locale :integer default("en")
-
# name :string not null
-
# settings :jsonb
-
# status :integer default("active")
-
# support_email :string(100)
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
# Indexes
-
#
-
# index_accounts_on_status (status)
-
#
-
-
1
class Account < ApplicationRecord
-
# used for single column multi flags
-
1
include FlagShihTzu
-
1
include Reportable
-
1
include Featurable
-
1
include CacheKeys
-
-
SETTINGS_PARAMS_SCHEMA = {
-
1
'type': 'object',
-
'properties':
-
{
-
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
-
'auto_resolve_message': { 'type': %w[string null] }
-
},
-
'required': [],
-
'additionalProperties': false
-
}.to_json.freeze
-
-
1
DEFAULT_QUERY_SETTING = {
-
flag_query_mode: :bit_operator,
-
check_for_column: false
-
}.freeze
-
-
1
validates :domain, length: { maximum: 100 }
-
1
validates_with JsonSchemaValidator,
-
schema: SETTINGS_PARAMS_SCHEMA,
-
6
attribute_resolver: ->(record) { record.settings }
-
-
1
store_accessor :settings, :auto_resolve_after, :auto_resolve_message
-
-
1
has_many :account_users, dependent: :destroy_async
-
1
has_many :agent_bot_inboxes, dependent: :destroy_async
-
1
has_many :agent_bots, dependent: :destroy_async
-
1
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
-
1
has_many :articles, dependent: :destroy_async, class_name: '::Article'
-
1
has_many :automation_rules, dependent: :destroy_async
-
1
has_many :macros, dependent: :destroy_async
-
1
has_many :campaigns, dependent: :destroy_async
-
1
has_many :canned_responses, dependent: :destroy_async
-
1
has_many :categories, dependent: :destroy_async, class_name: '::Category'
-
1
has_many :contacts, dependent: :destroy_async
-
1
has_many :conversations, dependent: :destroy_async
-
1
has_many :csat_survey_responses, dependent: :destroy_async
-
1
has_many :custom_attribute_definitions, dependent: :destroy_async
-
1
has_many :custom_filters, dependent: :destroy_async
-
1
has_many :dashboard_apps, dependent: :destroy_async
-
1
has_many :data_imports, dependent: :destroy_async
-
1
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
-
1
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
-
1
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
-
1
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
-
1
has_many :inboxes, dependent: :destroy_async
-
1
has_many :labels, dependent: :destroy_async
-
1
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
-
1
has_many :mentions, dependent: :destroy_async
-
1
has_many :messages, dependent: :destroy_async
-
1
has_many :notes, dependent: :destroy_async
-
1
has_many :notification_settings, dependent: :destroy_async
-
1
has_many :notifications, dependent: :destroy_async
-
1
has_many :portals, dependent: :destroy_async, class_name: '::Portal'
-
1
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
-
1
has_many :teams, dependent: :destroy_async
-
1
has_many :telegram_bots, dependent: :destroy_async
-
1
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
-
1
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
-
1
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
-
1
has_many :users, through: :account_users
-
1
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
-
1
has_many :webhooks, dependent: :destroy_async
-
1
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
-
1
has_many :working_hours, dependent: :destroy_async
-
-
1
has_one_attached :contacts_export
-
-
39
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
-
1
enum status: { active: 0, suspended: 1 }
-
-
1
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
-
-
1
before_validation :validate_limit_keys
-
1
after_create_commit :notify_creation
-
1
after_destroy :remove_account_sequences
-
-
1
def agents
-
9
users.where(account_users: { role: :agent })
-
end
-
-
1
def administrators
-
30
users.where(account_users: { role: :administrator })
-
end
-
-
1
def all_conversation_tags
-
# returns array of tags
-
conversation_ids = conversations.pluck(:id)
-
ActsAsTaggableOn::Tagging.includes(:tag)
-
.where(context: 'labels',
-
taggable_type: 'Conversation',
-
taggable_id: conversation_ids)
-
.map { |tagging| tagging.tag.name }
-
end
-
-
1
def webhook_data
-
{
-
id: id,
-
name: name
-
}
-
end
-
-
1
def inbound_email_domain
-
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
-
false)
-
end
-
-
1
def support_email
-
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
-
end
-
-
1
def usage_limits
-
{
-
agents: ChatwootApp.max_limit.to_i,
-
inboxes: ChatwootApp.max_limit.to_i
-
}
-
end
-
-
1
def locale_english_name
-
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
-
# the format is `<locale_code>_<country_code>`
-
# we need to extract the language code from the locale
-
account_locale = locale&.split('_')&.first
-
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
-
end
-
-
1
private
-
-
1
def notify_creation
-
6
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
-
end
-
-
1
trigger.after(:insert).for_each(:row) do
-
1
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
-
end
-
-
1
trigger.name('camp_dpid_before_insert').after(:insert).for_each(:row) do
-
1
"execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);"
-
end
-
-
1
def validate_limit_keys
-
# method overridden in enterprise module
-
end
-
-
1
def remove_account_sequences
-
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
-
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
-
end
-
end
-
-
1
Account.prepend_mod_with('Account')
-
1
Account.prepend_mod_with('Account::PlanUsageAndLimits')
-
1
Account.include_mod_with('Concerns::Account')
-
1
Account.include_mod_with('Audit::Account')
-
# == Schema Information
-
#
-
# Table name: account_users
-
#
-
# id :bigint not null, primary key
-
# active_at :datetime
-
# auto_offline :boolean default(TRUE), not null
-
# availability :integer default("online"), not null
-
# role :integer default("agent")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint
-
# custom_role_id :bigint
-
# inviter_id :bigint
-
# user_id :bigint
-
#
-
# Indexes
-
#
-
# index_account_users_on_account_id (account_id)
-
# index_account_users_on_custom_role_id (custom_role_id)
-
# index_account_users_on_user_id (user_id)
-
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
-
#
-
-
1
class AccountUser < ApplicationRecord
-
1
include AvailabilityStatusable
-
-
1
belongs_to :account
-
1
belongs_to :user
-
1
belongs_to :inviter, class_name: 'User', optional: true
-
-
1
enum role: { agent: 0, administrator: 1 }
-
1
enum availability: { online: 0, offline: 1, busy: 2 }
-
-
1
accepts_nested_attributes_for :account
-
-
1
after_create_commit :notify_creation, :create_notification_setting
-
1
after_destroy :notify_deletion, :remove_user_from_account
-
1
after_save :update_presence_in_redis, if: :saved_change_to_availability?
-
-
1
validates :user_id, uniqueness: { scope: :account_id }
-
-
1
def create_notification_setting
-
3
setting = user.notification_settings.new(account_id: account.id)
-
3
setting.selected_email_flags = [:email_conversation_assignment]
-
3
setting.selected_push_flags = [:push_conversation_assignment]
-
3
setting.save!
-
end
-
-
1
def remove_user_from_account
-
::Agents::DestroyJob.perform_later(account, user)
-
end
-
-
1
def permissions
-
administrator? ? ['administrator'] : ['agent']
-
end
-
-
1
def push_event_data
-
{
-
id: id,
-
availability: availability,
-
role: role,
-
user_id: user_id
-
}
-
end
-
-
1
private
-
-
1
def notify_creation
-
3
Rails.configuration.dispatcher.dispatch(AGENT_ADDED, Time.zone.now, account: account)
-
end
-
-
1
def notify_deletion
-
Rails.configuration.dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: account)
-
end
-
-
1
def update_presence_in_redis
-
OnlineStatusTracker.set_status(account.id, user.id, availability)
-
end
-
end
-
-
1
AccountUser.prepend_mod_with('AccountUser')
-
1
AccountUser.include_mod_with('Audit::AccountUser')
-
1
AccountUser.include_mod_with('Concerns::AccountUser')
-
# == Schema Information
-
#
-
# Table name: agent_bots
-
#
-
# id :bigint not null, primary key
-
# bot_config :jsonb
-
# bot_type :integer default("webhook")
-
# description :string
-
# name :string
-
# outgoing_url :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint
-
#
-
# Indexes
-
#
-
# index_agent_bots_on_account_id (account_id)
-
#
-
-
1
class AgentBot < ApplicationRecord
-
1
include AccessTokenable
-
1
include Avatarable
-
-
1
has_many :agent_bot_inboxes, dependent: :destroy_async
-
1
has_many :inboxes, through: :agent_bot_inboxes
-
1
has_many :messages, as: :sender, dependent: :nullify
-
1
belongs_to :account, optional: true
-
1
enum bot_type: { webhook: 0 }
-
-
1
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
-
-
1
def available_name
-
name
-
end
-
-
1
def push_event_data(inbox = nil)
-
{
-
id: id,
-
name: name,
-
avatar_url: avatar_url || inbox&.avatar_url,
-
type: 'agent_bot'
-
}
-
end
-
-
1
def webhook_data
-
{
-
id: id,
-
name: name,
-
type: 'agent_bot'
-
}
-
end
-
-
1
def system_bot?
-
account.nil?
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: agent_bot_inboxes
-
#
-
# id :bigint not null, primary key
-
# status :integer default("active")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
# agent_bot_id :integer
-
# inbox_id :integer
-
#
-
-
1
class AgentBotInbox < ApplicationRecord
-
1
validates :inbox_id, presence: true
-
1
validates :agent_bot_id, presence: true
-
1
before_validation :ensure_account_id
-
-
1
belongs_to :inbox
-
1
belongs_to :agent_bot
-
1
belongs_to :account
-
1
enum status: { active: 0, inactive: 1 }
-
-
1
private
-
-
1
def ensure_account_id
-
self.account_id = inbox&.account_id
-
end
-
end
-
1
class ApplicationRecord < ActiveRecord::Base
-
1
include Events::Types
-
1
self.abstract_class = true
-
-
1
before_validation :validates_column_content_length
-
-
# the models that exposed in email templates through liquid
-
1
def droppables
-
%w[Account Channel Conversation Inbox User Message]
-
end
-
-
# ModelDrop class should exist in app/drops
-
1
def to_drop
-
return unless droppables.include?(self.class.name)
-
-
"#{self.class.name}Drop".constantize.new(self)
-
end
-
-
1
private
-
-
# Generic validation for all columns of type string and text
-
# Validates the length of the column to prevent DOS via large payloads
-
# if a custom length validation is already present, skip the validation
-
1
def validates_column_content_length
-
135
self.class.columns.each do |column|
-
1998
check_and_validate_content_length(column) if column_of_type_string_or_text?(column)
-
end
-
end
-
-
1
def column_of_type_string_or_text?(column)
-
1998
%i[string text].include?(column.type)
-
end
-
-
1
def check_and_validate_content_length(column)
-
597
length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length }
-
384
validate_content_length(column) if length_validator.blank?
-
end
-
-
1
def validate_content_length(column)
-
348
max_length = column.type == :text ? 20_000 : 255
-
348
return if self[column.name].nil? || self[column.name].length <= max_length
-
-
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
-
end
-
-
1
def normalize_empty_string_to_nil(attrs = [])
-
attrs.each do |attr|
-
self[attr] = nil if self[attr].blank?
-
end
-
end
-
end
-
-
1
ApplicationRecord.prepend_mod_with('ApplicationRecord')
-
# == Schema Information
-
#
-
# Table name: articles
-
#
-
# id :bigint not null, primary key
-
# content :text
-
# description :text
-
# locale :string default("en"), not null
-
# meta :jsonb
-
# position :integer
-
# slug :string not null
-
# status :integer
-
# title :string
-
# views :integer
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# associated_article_id :bigint
-
# author_id :bigint
-
# category_id :integer
-
# folder_id :integer
-
# portal_id :integer not null
-
#
-
# Indexes
-
#
-
# index_articles_on_account_id (account_id)
-
# index_articles_on_associated_article_id (associated_article_id)
-
# index_articles_on_author_id (author_id)
-
# index_articles_on_portal_id (portal_id)
-
# index_articles_on_slug (slug) UNIQUE
-
# index_articles_on_status (status)
-
# index_articles_on_views (views)
-
#
-
class Article < ApplicationRecord
-
include PgSearch::Model
-
-
has_many :associated_articles,
-
class_name: :Article,
-
foreign_key: :associated_article_id,
-
dependent: :nullify,
-
inverse_of: 'root_article'
-
-
belongs_to :root_article,
-
class_name: :Article,
-
foreign_key: :associated_article_id,
-
inverse_of: :associated_articles,
-
optional: true
-
belongs_to :account
-
belongs_to :category, optional: true
-
belongs_to :portal
-
belongs_to :author, class_name: 'User', inverse_of: :articles
-
-
before_validation :ensure_account_id
-
before_validation :ensure_article_slug
-
before_validation :ensure_locale_in_article
-
-
validates :account_id, presence: true
-
validates :author_id, presence: true
-
validates :title, presence: true
-
validates :content, presence: true
-
-
# ensuring that the position is always set correctly
-
before_create :add_position_to_article
-
after_save :category_id_changed_action, if: :saved_change_to_category_id?
-
-
enum status: { draft: 0, published: 1, archived: 2 }
-
-
scope :search_by_category_slug, ->(category_slug) { where(categories: { slug: category_slug }) if category_slug.present? }
-
scope :search_by_category_locale, ->(locale) { where(categories: { locale: locale }) if locale.present? }
-
scope :search_by_locale, ->(locale) { where(locale: locale) if locale.present? }
-
scope :search_by_author, ->(author_id) { where(author_id: author_id) if author_id.present? }
-
scope :search_by_status, ->(status) { where(status: status) if status.present? }
-
scope :order_by_updated_at, -> { reorder(updated_at: :desc) }
-
scope :order_by_position, -> { reorder(position: :asc) }
-
scope :order_by_views, -> { reorder(views: :desc) }
-
-
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
-
pg_search_scope(
-
:text_search,
-
against: %i[
-
title
-
description
-
content
-
],
-
using: {
-
tsearch: {
-
prefix: true
-
}
-
}
-
)
-
-
def self.search(params)
-
records = left_outer_joins(
-
:category
-
).search_by_category_slug(
-
params[:category_slug]
-
).search_by_locale(params[:locale]).search_by_author(params[:author_id]).search_by_status(params[:status])
-
-
records = records.text_search(params[:query]) if params[:query].present?
-
records
-
end
-
-
def associate_root_article(associated_article_id)
-
article = portal.articles.find(associated_article_id) if associated_article_id.present?
-
-
return if article.nil?
-
-
root_article_id = self.class.find_root_article_id(article)
-
-
update(associated_article_id: root_article_id) if root_article_id.present?
-
end
-
-
# Make sure we always associate the parent's associated id to avoid the deeper associations od articles.
-
def self.find_root_article_id(article)
-
article.associated_article_id || article.id
-
end
-
-
def draft!
-
update(status: :draft)
-
end
-
-
def increment_view_count
-
# rubocop:disable Rails/SkipsModelValidations
-
update_column(:views, views? ? views + 1 : 1)
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
-
def self.update_positions(positions_hash)
-
positions_hash.each do |article_id, new_position|
-
# Find the article by its ID and update its position
-
article = Article.find(article_id)
-
article.update!(position: new_position)
-
end
-
end
-
-
private
-
-
def category_id_changed_action
-
# We need to update the position of the article in the new category
-
return unless persisted?
-
-
# this means the article is just created
-
# and the category_id is newly set
-
# and the position is already present
-
return if created_at_before_last_save.nil? && position.present? && category_id_before_last_save.nil?
-
-
update_article_position_in_category
-
end
-
-
def ensure_locale_in_article
-
self.locale = if category.present?
-
category.locale
-
else
-
locale.presence || portal.default_locale
-
end
-
end
-
-
def add_position_to_article
-
# on creation if a position is already present, ignore it
-
return if position.present?
-
-
update_article_position_in_category
-
end
-
-
def update_article_position_in_category
-
max_position = Article.where(category_id: category_id, account_id: account_id).maximum(:position)
-
-
new_position = max_position.present? ? max_position + 10 : 10
-
-
# update column to avoid validations if the article is already persisted
-
if persisted?
-
# rubocop:disable Rails/SkipsModelValidations
-
update_column(:position, new_position)
-
# rubocop:enable Rails/SkipsModelValidations
-
else
-
self.position = new_position
-
end
-
end
-
-
def ensure_account_id
-
self.account_id = portal&.account_id
-
end
-
-
def ensure_article_slug
-
self.slug ||= "#{Time.now.utc.to_i}-#{title.underscore.parameterize(separator: '-')}" if title.present?
-
end
-
end
-
Article.include_mod_with('Concerns::Article')
-
# == Schema Information
-
#
-
# Table name: attachments
-
#
-
# id :integer not null, primary key
-
# coordinates_lat :float default(0.0)
-
# coordinates_long :float default(0.0)
-
# extension :string
-
# external_url :string
-
# fallback_title :string
-
# file_type :integer default("image")
-
# meta :jsonb
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# message_id :integer not null
-
#
-
# Indexes
-
#
-
# index_attachments_on_account_id (account_id)
-
# index_attachments_on_message_id (message_id)
-
#
-
-
1
class Attachment < ApplicationRecord
-
1
include Rails.application.routes.url_helpers
-
-
ACCEPTABLE_FILE_TYPES = %w[
-
1
text/csv text/plain text/rtf
-
application/json application/pdf
-
application/zip application/x-7z-compressed application/vnd.rar application/x-tar
-
application/msword application/vnd.ms-excel application/vnd.ms-powerpoint application/rtf
-
application/vnd.oasis.opendocument.text
-
application/vnd.openxmlformats-officedocument.presentationml.presentation
-
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
-
application/vnd.openxmlformats-officedocument.wordprocessingml.document
-
].freeze
-
1
belongs_to :account
-
1
belongs_to :message
-
1
has_one_attached :file
-
1
validate :acceptable_file
-
1
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
-
1
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
-
:contact => 8, :ig_reel => 9 }
-
-
1
def push_event_data
-
return unless file_type
-
return base_data.merge(location_metadata) if file_type.to_sym == :location
-
return base_data.merge(fallback_data) if file_type.to_sym == :fallback
-
return base_data.merge(contact_metadata) if file_type.to_sym == :contact
-
-
base_data.merge(file_metadata)
-
end
-
-
# NOTE: the URl returned does a 301 redirect to the actual file
-
1
def file_url
-
file.attached? ? url_for(file) : ''
-
end
-
-
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
-
1
def download_url
-
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank?
-
file.attached? ? file.blob.url : ''
-
end
-
-
1
def thumb_url
-
if file.attached? && file.representable?
-
url_for(file.representation(resize_to_fill: [250, nil]))
-
else
-
''
-
end
-
end
-
-
1
def with_attached_file?
-
[:image, :audio, :video, :file].include?(file_type.to_sym)
-
end
-
-
1
private
-
-
1
def file_metadata
-
metadata = {
-
extension: extension,
-
data_url: file_url,
-
thumb_url: thumb_url,
-
file_size: file.byte_size,
-
width: file.metadata[:width],
-
height: file.metadata[:height]
-
}
-
-
metadata[:data_url] = metadata[:thumb_url] = external_url if message.inbox.instagram? && message.incoming?
-
metadata
-
end
-
-
1
def location_metadata
-
{
-
coordinates_lat: coordinates_lat,
-
coordinates_long: coordinates_long,
-
fallback_title: fallback_title,
-
data_url: external_url
-
}
-
end
-
-
1
def fallback_data
-
{
-
fallback_title: fallback_title,
-
data_url: external_url
-
}
-
end
-
-
1
def base_data
-
{
-
id: id,
-
message_id: message_id,
-
file_type: file_type,
-
account_id: account_id
-
}
-
end
-
-
1
def contact_metadata
-
{
-
fallback_title: fallback_title,
-
meta: meta || {}
-
}
-
end
-
-
1
def should_validate_file?
-
return unless file.attached?
-
# we are only limiting attachment types in case of website widget
-
return unless message.inbox.channel_type == 'Channel::WebWidget'
-
-
true
-
end
-
-
1
def acceptable_file
-
return unless should_validate_file?
-
-
validate_file_size(file.byte_size)
-
validate_file_content_type(file.content_type)
-
end
-
-
1
def validate_file_content_type(file_content_type)
-
errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
-
end
-
-
1
def validate_file_size(byte_size)
-
errors.add(:file, 'size is too big') if byte_size > 40.megabytes
-
end
-
-
1
def media_file?(file_content_type)
-
file_content_type.start_with?('image/', 'video/', 'audio/')
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: automation_rules
-
#
-
# id :bigint not null, primary key
-
# actions :jsonb not null
-
# active :boolean default(TRUE), not null
-
# conditions :jsonb not null
-
# description :text
-
# event_name :string not null
-
# name :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_automation_rules_on_account_id (account_id)
-
#
-
class AutomationRule < ApplicationRecord
-
include Rails.application.routes.url_helpers
-
include Reauthorizable
-
-
belongs_to :account
-
has_many_attached :files
-
-
validate :json_conditions_format
-
validate :json_actions_format
-
validate :query_operator_presence
-
validate :query_operator_value
-
validates :account_id, presence: true
-
-
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
-
-
scope :active, -> { where(active: true) }
-
-
def conditions_attributes
-
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
-
mail_subject phone_number priority conversation_language]
-
end
-
-
def actions_attributes
-
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
-
send_attachment change_status resolve_conversation snooze_conversation change_priority send_email_transcript].freeze
-
end
-
-
def file_base_data
-
files.map do |file|
-
{
-
id: file.id,
-
automation_rule_id: id,
-
file_type: file.content_type,
-
account_id: account_id,
-
file_url: url_for(file),
-
blob_id: file.blob_id,
-
filename: file.filename.to_s
-
}
-
end
-
end
-
-
private
-
-
def json_conditions_format
-
return if conditions.blank?
-
-
attributes = conditions.map { |obj, _| obj['attribute_key'] }
-
conditions = attributes - conditions_attributes
-
conditions -= account.custom_attribute_definitions.pluck(:attribute_key)
-
errors.add(:conditions, "Automation conditions #{conditions.join(',')} not supported.") if conditions.any?
-
end
-
-
def json_actions_format
-
return if actions.blank?
-
-
attributes = actions.map { |obj, _| obj['action_name'] }
-
actions = attributes - actions_attributes
-
-
errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any?
-
end
-
-
def query_operator_presence
-
return if conditions.blank?
-
-
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
-
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
-
end
-
-
# This validation ensures logical operators are being used correctly in automation conditions.
-
# And we don't push any unsanitized query operators to the database.
-
def query_operator_value
-
conditions.each do |obj|
-
validate_single_condition(obj)
-
end
-
end
-
-
def validate_single_condition(condition)
-
query_operator = condition['query_operator']
-
-
return if query_operator.nil?
-
return if query_operator.empty?
-
-
operator = query_operator.upcase
-
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
-
end
-
end
-
-
AutomationRule.include_mod_with('Audit::AutomationRule')
-
AutomationRule.prepend_mod_with('AutomationRule')
-
# == Schema Information
-
#
-
# Table name: campaigns
-
#
-
# id :bigint not null, primary key
-
# audience :jsonb
-
# campaign_status :integer default("active"), not null
-
# campaign_type :integer default("ongoing"), not null
-
# description :text
-
# enabled :boolean default(TRUE)
-
# message :text not null
-
# scheduled_at :datetime
-
# title :string not null
-
# trigger_only_during_business_hours :boolean default(FALSE)
-
# trigger_rules :jsonb
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# display_id :integer not null
-
# inbox_id :bigint not null
-
# sender_id :integer
-
#
-
# Indexes
-
#
-
# index_campaigns_on_account_id (account_id)
-
# index_campaigns_on_campaign_status (campaign_status)
-
# index_campaigns_on_campaign_type (campaign_type)
-
# index_campaigns_on_inbox_id (inbox_id)
-
# index_campaigns_on_scheduled_at (scheduled_at)
-
#
-
1
class Campaign < ApplicationRecord
-
1
include UrlHelper
-
1
validates :account_id, presence: true
-
1
validates :inbox_id, presence: true
-
1
validates :title, presence: true
-
1
validates :message, presence: true
-
1
validate :validate_campaign_inbox
-
1
validate :validate_url
-
1
validate :prevent_completed_campaign_from_update, on: :update
-
1
validate :sender_must_belong_to_account
-
1
validate :inbox_must_belong_to_account
-
-
1
belongs_to :account
-
1
belongs_to :inbox
-
1
belongs_to :sender, class_name: 'User', optional: true
-
-
1
enum campaign_type: { ongoing: 0, one_off: 1 }
-
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
-
1
enum campaign_status: { active: 0, completed: 1 }
-
-
1
has_many :conversations, dependent: :nullify, autosave: true
-
-
1
before_validation :ensure_correct_campaign_attributes
-
1
after_commit :set_display_id, unless: :display_id?
-
-
1
def trigger!
-
return unless one_off?
-
return if completed?
-
-
Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS'
-
Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms'
-
end
-
-
1
private
-
-
1
def set_display_id
-
reload
-
end
-
-
1
def validate_campaign_inbox
-
return unless inbox
-
-
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type
-
end
-
-
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
-
1
def ensure_correct_campaign_attributes
-
return if inbox.blank?
-
-
if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type)
-
self.campaign_type = 'one_off'
-
self.scheduled_at ||= Time.now.utc
-
else
-
self.campaign_type = 'ongoing'
-
self.scheduled_at = nil
-
end
-
end
-
-
1
def validate_url
-
return unless trigger_rules['url']
-
-
use_http_protocol = trigger_rules['url'].starts_with?('http://') || trigger_rules['url'].starts_with?('https://')
-
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
-
end
-
-
1
def inbox_must_belong_to_account
-
return unless inbox
-
-
return if inbox.account_id == account_id
-
-
errors.add(:inbox_id, 'must belong to the same account as the campaign')
-
end
-
-
1
def sender_must_belong_to_account
-
return unless sender
-
-
return if account.users.exists?(id: sender.id)
-
-
errors.add(:sender_id, 'must belong to the same account as the campaign')
-
end
-
-
1
def prevent_completed_campaign_from_update
-
errors.add :status, 'The campaign is already completed' if !campaign_status_changed? && completed?
-
end
-
-
# creating db triggers
-
1
trigger.before(:insert).for_each(:row) do
-
1
"NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);"
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: canned_responses
-
#
-
# id :integer not null, primary key
-
# content :text
-
# short_code :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
-
class CannedResponse < ApplicationRecord
-
validates :content, presence: true
-
validates :short_code, presence: true
-
validates :account, presence: true
-
validates :short_code, uniqueness: { scope: :account_id }
-
-
belongs_to :account
-
-
scope :order_by_search, lambda { |search|
-
short_code_starts_with = sanitize_sql_array(['WHEN short_code ILIKE ? THEN 1', "#{search}%"])
-
short_code_like = sanitize_sql_array(['WHEN short_code ILIKE ? THEN 0.5', "%#{search}%"])
-
content_like = sanitize_sql_array(['WHEN content ILIKE ? THEN 0.2', "%#{search}%"])
-
-
order_clause = "CASE #{short_code_starts_with} #{short_code_like} #{content_like} ELSE 0 END"
-
-
order(Arel.sql(order_clause) => :desc)
-
}
-
end
-
# == Schema Information
-
#
-
# Table name: categories
-
#
-
# id :bigint not null, primary key
-
# description :text
-
# icon :string default("")
-
# locale :string default("en")
-
# name :string
-
# position :integer
-
# slug :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# associated_category_id :bigint
-
# parent_category_id :bigint
-
# portal_id :integer not null
-
#
-
# Indexes
-
#
-
# index_categories_on_associated_category_id (associated_category_id)
-
# index_categories_on_locale (locale)
-
# index_categories_on_locale_and_account_id (locale,account_id)
-
# index_categories_on_parent_category_id (parent_category_id)
-
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
-
#
-
class Category < ApplicationRecord
-
belongs_to :account
-
belongs_to :portal
-
has_many :folders, dependent: :destroy_async
-
has_many :articles, dependent: :nullify
-
has_many :category_related_categories,
-
class_name: :RelatedCategory,
-
dependent: :destroy_async
-
has_many :related_categories,
-
through: :category_related_categories,
-
class_name: :Category,
-
dependent: :nullify
-
has_many :sub_categories,
-
class_name: :Category,
-
foreign_key: :parent_category_id,
-
dependent: :nullify,
-
inverse_of: 'parent_category'
-
has_many :associated_categories,
-
class_name: :Category,
-
foreign_key: :associated_category_id,
-
dependent: :nullify,
-
inverse_of: 'root_category'
-
belongs_to :parent_category, class_name: :Category, optional: true
-
belongs_to :root_category,
-
class_name: :Category,
-
foreign_key: :associated_category_id,
-
inverse_of: :associated_categories,
-
optional: true
-
-
before_validation :ensure_account_id
-
validates :account_id, presence: true
-
validates :slug, presence: true
-
validates :name, presence: true
-
validate :allowed_locales
-
validates :locale, uniqueness: { scope: %i[slug portal_id],
-
message: I18n.t('errors.categories.locale.unique') }
-
accepts_nested_attributes_for :related_categories
-
-
scope :search_by_locale, ->(locale) { where(locale: locale) if locale.present? }
-
-
def self.search(params)
-
search_by_locale(params[:locale]).page(current_page(params)).order(position: :asc)
-
end
-
-
def self.current_page(params)
-
params[:page] || 1
-
end
-
-
private
-
-
def ensure_account_id
-
self.account_id = portal&.account_id
-
end
-
-
def allowed_locales
-
return if portal.blank?
-
-
allowed_locales = portal.config['allowed_locales']
-
-
return true if allowed_locales.include?(locale)
-
-
errors.add(:locale, "#{locale} of category is not part of portal's #{allowed_locales}.")
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_api
-
#
-
# id :bigint not null, primary key
-
# additional_attributes :jsonb
-
# hmac_mandatory :boolean default(FALSE)
-
# hmac_token :string
-
# identifier :string
-
# webhook_url :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_channel_api_on_hmac_token (hmac_token) UNIQUE
-
# index_channel_api_on_identifier (identifier) UNIQUE
-
#
-
-
class Channel::Api < ApplicationRecord
-
include Channelable
-
-
self.table_name = 'channel_api'
-
EDITABLE_ATTRS = [:webhook_url, :hmac_mandatory, { additional_attributes: {} }].freeze
-
-
has_secure_token :identifier
-
has_secure_token :hmac_token
-
validate :ensure_valid_agent_reply_time_window
-
validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
-
-
def name
-
'API'
-
end
-
-
private
-
-
def ensure_valid_agent_reply_time_window
-
return if additional_attributes['agent_reply_time_window'].blank?
-
return if additional_attributes['agent_reply_time_window'].to_i.positive?
-
-
errors.add(:agent_reply_time_window, 'agent_reply_time_window must be greater than 0')
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_email
-
#
-
# id :bigint not null, primary key
-
# email :string not null
-
# forward_to_email :string not null
-
# imap_address :string default("")
-
# imap_enable_ssl :boolean default(TRUE)
-
# imap_enabled :boolean default(FALSE)
-
# imap_login :string default("")
-
# imap_password :string default("")
-
# imap_port :integer default(0)
-
# provider :string
-
# provider_config :jsonb
-
# smtp_address :string default("")
-
# smtp_authentication :string default("login")
-
# smtp_domain :string default("")
-
# smtp_enable_ssl_tls :boolean default(FALSE)
-
# smtp_enable_starttls_auto :boolean default(TRUE)
-
# smtp_enabled :boolean default(FALSE)
-
# smtp_login :string default("")
-
# smtp_openssl_verify_mode :string default("none")
-
# smtp_password :string default("")
-
# smtp_port :integer default(0)
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_channel_email_on_email (email) UNIQUE
-
# index_channel_email_on_forward_to_email (forward_to_email) UNIQUE
-
#
-
-
class Channel::Email < ApplicationRecord
-
include Channelable
-
include Reauthorizable
-
-
AUTHORIZATION_ERROR_THRESHOLD = 10
-
-
self.table_name = 'channel_email'
-
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
-
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
-
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider].freeze
-
-
validates :email, uniqueness: true
-
validates :forward_to_email, uniqueness: true
-
-
before_validation :ensure_forward_to_email, on: :create
-
-
def name
-
'Email'
-
end
-
-
def microsoft?
-
provider == 'microsoft'
-
end
-
-
def google?
-
provider == 'google'
-
end
-
-
def legacy_google?
-
imap_enabled && imap_address == 'imap.gmail.com'
-
end
-
-
private
-
-
def ensure_forward_to_email
-
self.forward_to_email ||= "#{SecureRandom.hex}@#{account.inbound_email_domain}"
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_facebook_pages
-
#
-
# id :integer not null, primary key
-
# page_access_token :string not null
-
# user_access_token :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# instagram_id :string
-
# page_id :string not null
-
#
-
# Indexes
-
#
-
# index_channel_facebook_pages_on_page_id (page_id)
-
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
-
#
-
-
class Channel::FacebookPage < ApplicationRecord
-
include Channelable
-
include Reauthorizable
-
-
self.table_name = 'channel_facebook_pages'
-
-
validates :page_id, uniqueness: { scope: :account_id }
-
-
after_create_commit :subscribe
-
before_destroy :unsubscribe
-
-
def name
-
'Facebook'
-
end
-
-
def create_contact_inbox(instagram_id, name)
-
@contact_inbox = ::ContactInboxWithContactBuilder.new({
-
source_id: instagram_id,
-
inbox: inbox,
-
contact_attributes: { name: name }
-
}).perform
-
end
-
-
def subscribe
-
# ref https://developers.facebook.com/docs/messenger-platform/reference/webhook-events
-
Facebook::Messenger::Subscriptions.subscribe(
-
access_token: page_access_token,
-
subscribed_fields: %w[
-
messages message_deliveries message_echoes message_reads standby messaging_handovers
-
]
-
)
-
rescue StandardError => e
-
Rails.logger.debug { "Rescued: #{e.inspect}" }
-
true
-
end
-
-
def unsubscribe
-
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
-
rescue StandardError => e
-
Rails.logger.debug { "Rescued: #{e.inspect}" }
-
true
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_instagram
-
#
-
# id :bigint not null, primary key
-
# access_token :string not null
-
# expires_at :datetime not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# instagram_id :string not null
-
#
-
# Indexes
-
#
-
# index_channel_instagram_on_instagram_id (instagram_id) UNIQUE
-
#
-
class Channel::Instagram < ApplicationRecord
-
include Channelable
-
include Reauthorizable
-
self.table_name = 'channel_instagram'
-
-
AUTHORIZATION_ERROR_THRESHOLD = 1
-
-
validates :access_token, presence: true
-
validates :instagram_id, uniqueness: true, presence: true
-
-
after_create_commit :subscribe
-
before_destroy :unsubscribe
-
-
def name
-
'Instagram'
-
end
-
-
def create_contact_inbox(instagram_id, name)
-
@contact_inbox = ::ContactInboxWithContactBuilder.new({
-
source_id: instagram_id,
-
inbox: inbox,
-
contact_attributes: { name: name }
-
}).perform
-
end
-
-
def subscribe
-
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
-
HTTParty.post(
-
"https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps",
-
query: {
-
subscribed_fields: %w[messages message_reactions messaging_seen],
-
access_token: access_token
-
}
-
)
-
rescue StandardError => e
-
Rails.logger.debug { "Rescued: #{e.inspect}" }
-
true
-
end
-
-
def unsubscribe
-
HTTParty.delete(
-
"https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps",
-
query: {
-
access_token: access_token
-
}
-
)
-
true
-
rescue StandardError => e
-
Rails.logger.debug { "Rescued: #{e.inspect}" }
-
true
-
end
-
-
def access_token
-
Instagram::RefreshOauthTokenService.new(channel: self).access_token
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_line
-
#
-
# id :bigint not null, primary key
-
# line_channel_secret :string not null
-
# line_channel_token :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# line_channel_id :string not null
-
#
-
# Indexes
-
#
-
# index_channel_line_on_line_channel_id (line_channel_id) UNIQUE
-
#
-
-
class Channel::Line < ApplicationRecord
-
include Channelable
-
-
self.table_name = 'channel_line'
-
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
-
-
validates :line_channel_id, uniqueness: true, presence: true
-
validates :line_channel_secret, presence: true
-
validates :line_channel_token, presence: true
-
-
def name
-
'LINE'
-
end
-
-
def client
-
@client ||= Line::Bot::Client.new do |config|
-
config.channel_id = line_channel_id
-
config.channel_secret = line_channel_secret
-
config.channel_token = line_channel_token
-
end
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_sms
-
#
-
# id :bigint not null, primary key
-
# phone_number :string not null
-
# provider :string default("default")
-
# provider_config :jsonb
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_channel_sms_on_phone_number (phone_number) UNIQUE
-
#
-
-
class Channel::Sms < ApplicationRecord
-
include Channelable
-
-
self.table_name = 'channel_sms'
-
EDITABLE_ATTRS = [:phone_number, { provider_config: {} }].freeze
-
-
validates :phone_number, presence: true, uniqueness: true
-
# before_save :validate_provider_config
-
-
def name
-
'Sms'
-
end
-
-
# all this should happen in provider service . but hack mode on
-
def api_base_path
-
'https://messaging.bandwidth.com/api/v2'
-
end
-
-
def send_message(contact_number, message)
-
body = message_body(contact_number, message.content)
-
body['media'] = message.attachments.map(&:download_url) if message.attachments.present?
-
-
send_to_bandwidth(body, message)
-
end
-
-
def send_text_message(contact_number, message_content)
-
body = message_body(contact_number, message_content)
-
send_to_bandwidth(body)
-
end
-
-
private
-
-
def message_body(contact_number, message_content)
-
{
-
'to' => contact_number,
-
'from' => phone_number,
-
'text' => message_content,
-
'applicationId' => provider_config['application_id']
-
}
-
end
-
-
def send_to_bandwidth(body, message = nil)
-
response = HTTParty.post(
-
"#{api_base_path}/users/#{provider_config['account_id']}/messages",
-
basic_auth: bandwidth_auth,
-
headers: { 'Content-Type' => 'application/json' },
-
body: body.to_json
-
)
-
-
if response.success?
-
response.parsed_response['id']
-
else
-
handle_error(response, message)
-
nil
-
end
-
end
-
-
def handle_error(response, message)
-
Rails.logger.error("[#{account_id}] Error sending SMS: #{response.parsed_response['description']}")
-
return if message.blank?
-
-
# https://dev.bandwidth.com/apis/messaging-apis/messaging/#tag/Messages/operation/createMessage
-
message.external_error = response.parsed_response['description']
-
message.status = :failed
-
message.save!
-
end
-
-
def bandwidth_auth
-
{ username: provider_config['api_key'], password: provider_config['api_secret'] }
-
end
-
-
# Extract later into provider Service
-
# let's revisit later
-
def validate_provider_config
-
response = HTTParty.post(
-
"#{api_base_path}/users/#{provider_config['account_id']}/messages",
-
basic_auth: bandwidth_auth,
-
headers: { 'Content-Type': 'application/json' }
-
)
-
errors.add(:provider_config, 'error setting up') unless response.success?
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_telegram
-
#
-
# id :bigint not null, primary key
-
# bot_name :string
-
# bot_token :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_channel_telegram_on_bot_token (bot_token) UNIQUE
-
#
-
-
class Channel::Telegram < ApplicationRecord
-
include Channelable
-
-
self.table_name = 'channel_telegram'
-
EDITABLE_ATTRS = [:bot_token].freeze
-
-
before_validation :ensure_valid_bot_token, on: :create
-
validates :bot_token, presence: true, uniqueness: true
-
before_save :setup_telegram_webhook
-
-
def name
-
'Telegram'
-
end
-
-
def telegram_api_url
-
"https://api.telegram.org/bot#{bot_token}"
-
end
-
-
def send_message_on_telegram(message)
-
message_id = send_message(message) if message.content.present?
-
message_id = Telegram::SendAttachmentsService.new(message: message).perform if message.attachments.present?
-
message_id
-
end
-
-
def get_telegram_profile_image(user_id)
-
# get profile image from telegram
-
response = HTTParty.get("#{telegram_api_url}/getUserProfilePhotos", query: { user_id: user_id })
-
return nil unless response.success?
-
-
photos = response.parsed_response.dig('result', 'photos')
-
return if photos.blank?
-
-
get_telegram_file_path(photos.first.last['file_id'])
-
end
-
-
def get_telegram_file_path(file_id)
-
response = HTTParty.get("#{telegram_api_url}/getFile", query: { file_id: file_id })
-
return nil unless response.success?
-
-
"https://api.telegram.org/file/bot#{bot_token}/#{response.parsed_response['result']['file_path']}"
-
end
-
-
def process_error(message, response)
-
return unless response.parsed_response['ok'] == false
-
-
# https://github.com/TelegramBotAPI/errors/tree/master/json
-
message.external_error = "#{response.parsed_response['error_code']}, #{response.parsed_response['description']}"
-
message.status = :failed
-
message.save!
-
end
-
-
def chat_id(message)
-
message.conversation[:additional_attributes]['chat_id']
-
end
-
-
def reply_to_message_id(message)
-
message.content_attributes['in_reply_to_external_id']
-
end
-
-
private
-
-
def ensure_valid_bot_token
-
response = HTTParty.get("#{telegram_api_url}/getMe")
-
unless response.success?
-
errors.add(:bot_token, 'invalid token')
-
return
-
end
-
-
self.bot_name = response.parsed_response['result']['username']
-
end
-
-
def setup_telegram_webhook
-
HTTParty.post("#{telegram_api_url}/deleteWebhook")
-
response = HTTParty.post("#{telegram_api_url}/setWebhook",
-
body: {
-
url: "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/telegram/#{bot_token}"
-
})
-
errors.add(:bot_token, 'error setting up the webook') unless response.success?
-
end
-
-
def send_message(message)
-
response = message_request(chat_id(message), message.content, reply_markup(message), reply_to_message_id(message))
-
process_error(message, response)
-
response.parsed_response['result']['message_id'] if response.success?
-
end
-
-
def reply_markup(message)
-
return unless message.content_type == 'input_select'
-
-
{
-
one_time_keyboard: true,
-
inline_keyboard: message.content_attributes['items'].map do |item|
-
[{
-
text: item['title'],
-
callback_data: item['value']
-
}]
-
end
-
}.to_json
-
end
-
-
def convert_markdown_to_telegram_html(text)
-
# ref: https://core.telegram.org/bots/api#html-style
-
-
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
-
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
-
-
# convert markdown to html
-
html = CommonMarker.render_html(text).strip
-
-
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
-
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
-
attributes: %w[href])
-
-
# converted escaped br tags to \n
-
stripped_html.gsub('<br>', "\n")
-
end
-
-
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
-
text_payload = convert_markdown_to_telegram_html(text)
-
-
HTTParty.post("#{telegram_api_url}/sendMessage",
-
body: {
-
chat_id: chat_id,
-
text: text_payload,
-
reply_markup: reply_markup,
-
parse_mode: 'HTML',
-
reply_to_message_id: reply_to_message_id
-
})
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_twilio_sms
-
#
-
# id :bigint not null, primary key
-
# account_sid :string not null
-
# api_key_sid :string
-
# auth_token :string not null
-
# medium :integer default("sms")
-
# messaging_service_sid :string
-
# phone_number :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_channel_twilio_sms_on_account_sid_and_phone_number (account_sid,phone_number) UNIQUE
-
# index_channel_twilio_sms_on_messaging_service_sid (messaging_service_sid) UNIQUE
-
# index_channel_twilio_sms_on_phone_number (phone_number) UNIQUE
-
#
-
-
class Channel::TwilioSms < ApplicationRecord
-
include Channelable
-
include Rails.application.routes.url_helpers
-
-
self.table_name = 'channel_twilio_sms'
-
-
validates :account_sid, presence: true
-
# The same parameter is used to store api_key_secret if api_key authentication is opted
-
validates :auth_token, presence: true
-
-
# Must have _one_ of messaging_service_sid _or_ phone_number, and messaging_service_sid is preferred
-
validates :messaging_service_sid, uniqueness: true, presence: true, unless: :phone_number?
-
validates :phone_number, absence: true, if: :messaging_service_sid?
-
validates :phone_number, uniqueness: true, allow_nil: true
-
-
enum medium: { sms: 0, whatsapp: 1 }
-
-
def name
-
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
-
end
-
-
def send_message(to:, body:, media_url: nil)
-
params = send_message_from.merge(to: to, body: body)
-
params[:media_url] = media_url if media_url.present?
-
params[:status_callback] = twilio_delivery_status_index_url
-
client.messages.create(**params)
-
end
-
-
private
-
-
def client
-
if api_key_sid.present?
-
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
-
else
-
Twilio::REST::Client.new(account_sid, auth_token)
-
end
-
end
-
-
def send_message_from
-
if messaging_service_sid?
-
{ messaging_service_sid: messaging_service_sid }
-
else
-
{ from: phone_number }
-
end
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_twitter_profiles
-
#
-
# id :bigint not null, primary key
-
# tweets_enabled :boolean default(TRUE)
-
# twitter_access_token :string not null
-
# twitter_access_token_secret :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# profile_id :string not null
-
#
-
# Indexes
-
#
-
# index_channel_twitter_profiles_on_account_id_and_profile_id (account_id,profile_id) UNIQUE
-
#
-
-
class Channel::TwitterProfile < ApplicationRecord
-
include Channelable
-
-
self.table_name = 'channel_twitter_profiles'
-
-
validates :profile_id, uniqueness: { scope: :account_id }
-
-
before_destroy :unsubscribe
-
-
EDITABLE_ATTRS = [:tweets_enabled].freeze
-
-
def name
-
'Twitter'
-
end
-
-
def create_contact_inbox(profile_id, name, additional_attributes)
-
::ContactInboxWithContactBuilder.new({
-
source_id: profile_id,
-
inbox: inbox,
-
contact_attributes: { name: name, additional_attributes: additional_attributes }
-
}).perform
-
end
-
-
def twitter_client
-
Twitty::Facade.new do |config|
-
config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil)
-
config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil)
-
config.access_token = twitter_access_token
-
config.access_token_secret = twitter_access_token_secret
-
config.base_url = 'https://api.twitter.com'
-
config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '')
-
end
-
end
-
-
private
-
-
def unsubscribe
-
### Fix unsubscription with new endpoint
-
unsubscribe_response = twitter_client.remove_subscription(user_id: profile_id)
-
Rails.logger.info "TWITTER_UNSUBSCRIBE: #{unsubscribe_response.body}"
-
rescue StandardError => e
-
Rails.logger.error e
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_web_widgets
-
#
-
# id :integer not null, primary key
-
# continuity_via_email :boolean default(TRUE), not null
-
# feature_flags :integer default(7), not null
-
# hmac_mandatory :boolean default(FALSE)
-
# hmac_token :string
-
# pre_chat_form_enabled :boolean default(FALSE)
-
# pre_chat_form_options :jsonb
-
# reply_time :integer default("in_a_few_minutes")
-
# website_token :string
-
# website_url :string
-
# welcome_tagline :string
-
# welcome_title :string
-
# widget_color :string default("#1f93ff")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
#
-
# Indexes
-
#
-
# index_channel_web_widgets_on_hmac_token (hmac_token) UNIQUE
-
# index_channel_web_widgets_on_website_token (website_token) UNIQUE
-
#
-
-
1
class Channel::WebWidget < ApplicationRecord
-
1
include Channelable
-
1
include FlagShihTzu
-
-
1
self.table_name = 'channel_web_widgets'
-
1
EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
-
:continuity_via_email, :hmac_mandatory,
-
{ pre_chat_form_options: [:pre_chat_message, :require_email,
-
{ pre_chat_fields:
-
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
-
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
-
{ selected_feature_flags: [] }].freeze
-
-
1
before_validation :validate_pre_chat_options
-
1
validates :website_url, presence: true
-
1
validates :widget_color, presence: true
-
1
has_many :portals, foreign_key: 'channel_web_widget_id', dependent: :nullify, inverse_of: :channel_web_widget
-
-
1
has_secure_token :website_token
-
1
has_secure_token :hmac_token
-
-
1
has_flags 1 => :attachments,
-
2 => :emoji_picker,
-
3 => :end_conversation,
-
4 => :use_inbox_avatar_for_bot,
-
:column => 'feature_flags',
-
:check_for_column => false
-
-
1
enum reply_time: { in_a_few_minutes: 0, in_a_few_hours: 1, in_a_day: 2 }
-
-
1
def name
-
15
'Website'
-
end
-
-
1
def web_widget_script
-
"
-
<script>
-
(function(d,t) {
-
var BASE_URL=\"#{ENV.fetch('FRONTEND_URL', '')}\";
-
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
-
g.src=BASE_URL+\"/packs/js/sdk.js\";
-
g.defer = true;
-
g.async = true;
-
s.parentNode.insertBefore(g,s);
-
g.onload=function(){
-
window.chatwootSDK.run({
-
websiteToken: '#{website_token}',
-
baseUrl: BASE_URL
-
})
-
}
-
})(document,\"script\");
-
</script>
-
"
-
end
-
-
1
def validate_pre_chat_options
-
15
return if pre_chat_form_options.with_indifferent_access['pre_chat_fields'].present?
-
-
6
self.pre_chat_form_options = {
-
pre_chat_message: 'Share your queries or comments here.',
-
pre_chat_fields: [
-
{
-
'field_type': 'standard', 'label': 'Email Id', 'name': 'emailAddress', 'type': 'email', 'required': true, 'enabled': false
-
},
-
{
-
'field_type': 'standard', 'label': 'Full name', 'name': 'fullName', 'type': 'text', 'required': false, 'enabled': false
-
},
-
{
-
'field_type': 'standard', 'label': 'Phone number', 'name': 'phoneNumber', 'type': 'text', 'required': false, 'enabled': false
-
}
-
]
-
}
-
end
-
-
1
def create_contact_inbox(additional_attributes = {})
-
::ContactInboxWithContactBuilder.new({
-
inbox: inbox,
-
contact_attributes: { additional_attributes: additional_attributes }
-
}).perform
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: channel_whatsapp
-
#
-
# id :bigint not null, primary key
-
# message_templates :jsonb
-
# message_templates_last_updated :datetime
-
# phone_number :string not null
-
# provider :string default("default")
-
# provider_config :jsonb
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
-
#
-
-
class Channel::Whatsapp < ApplicationRecord
-
include Channelable
-
include Reauthorizable
-
-
self.table_name = 'channel_whatsapp'
-
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
-
-
# default at the moment is 360dialog lets change later.
-
PROVIDERS = %w[default whatsapp_cloud].freeze
-
before_validation :ensure_webhook_verify_token
-
-
validates :provider, inclusion: { in: PROVIDERS }
-
validates :phone_number, presence: true, uniqueness: true
-
validate :validate_provider_config
-
-
after_create :sync_templates
-
-
def name
-
'Whatsapp'
-
end
-
-
def provider_service
-
if provider == 'whatsapp_cloud'
-
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
-
else
-
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
-
end
-
end
-
-
def mark_message_templates_updated
-
# rubocop:disable Rails/SkipsModelValidations
-
update_column(:message_templates_last_updated, Time.zone.now)
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
-
delegate :send_message, to: :provider_service
-
delegate :send_template, to: :provider_service
-
delegate :sync_templates, to: :provider_service
-
delegate :media_url, to: :provider_service
-
delegate :api_headers, to: :provider_service
-
-
private
-
-
def ensure_webhook_verify_token
-
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud'
-
end
-
-
def validate_provider_config
-
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
-
end
-
end
-
1
module AccessTokenable
-
1
extend ActiveSupport::Concern
-
1
included do
-
2
has_one :access_token, as: :owner, dependent: :destroy_async
-
2
after_create :create_access_token
-
end
-
-
1
def create_access_token
-
3
AccessToken.create!(owner: self)
-
end
-
end
-
1
module AccountCacheRevalidator
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
3
after_commit :update_account_cache, on: [:create, :update, :destroy]
-
end
-
-
1
def update_account_cache
-
9
account.update_cache_key(self.class.name.underscore)
-
end
-
end
-
1
module ActivityMessageHandler
-
1
extend ActiveSupport::Concern
-
-
1
include PriorityActivityMessageHandler
-
1
include LabelActivityMessageHandler
-
1
include SlaActivityMessageHandler
-
1
include TeamActivityMessageHandler
-
-
1
private
-
-
1
def create_activity
-
6
user_name = determine_user_name
-
-
6
handle_status_change(user_name)
-
6
handle_priority_change(user_name)
-
6
handle_label_change(user_name)
-
6
handle_sla_policy_change(user_name)
-
end
-
-
1
def determine_user_name
-
6
Current.user&.name
-
end
-
-
1
def handle_status_change(user_name)
-
6
return unless saved_change_to_status?
-
-
status_change_activity(user_name)
-
end
-
-
1
def handle_priority_change(user_name)
-
6
return unless saved_change_to_priority?
-
-
priority_change_activity(user_name)
-
end
-
-
1
def handle_label_change(user_name)
-
6
return unless saved_change_to_label_list?
-
-
create_label_change(activity_message_owner(user_name))
-
end
-
-
1
def handle_sla_policy_change(user_name)
-
6
return unless saved_change_to_sla_policy_id?
-
-
sla_change_type = determine_sla_change_type
-
create_sla_change_activity(sla_change_type, activity_message_owner(user_name))
-
end
-
-
1
def status_change_activity(user_name)
-
content = if Current.executed_by.present?
-
automation_status_change_activity_content
-
else
-
user_status_change_activity_content(user_name)
-
end
-
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
-
1
def auto_resolve_message_key(minutes)
-
if minutes >= 1440 && (minutes % 1440).zero?
-
{ key: 'auto_resolved_days', count: minutes / 1440 }
-
elsif minutes >= 60 && (minutes % 60).zero?
-
{ key: 'auto_resolved_hours', count: minutes / 60 }
-
else
-
{ key: 'auto_resolved_minutes', count: minutes }
-
end
-
end
-
-
1
def user_status_change_activity_content(user_name)
-
if user_name
-
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
-
elsif Current.contact.present? && resolved?
-
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
-
elsif resolved?
-
message_data = auto_resolve_message_key(auto_resolve_after || 0)
-
I18n.t("conversations.activity.status.#{message_data[:key]}", count: message_data[:count])
-
end
-
end
-
-
1
def automation_status_change_activity_content
-
if Current.executed_by.instance_of?(AutomationRule)
-
I18n.t("conversations.activity.status.#{status}", user_name: I18n.t('automation.system_name'))
-
elsif Current.executed_by.instance_of?(Contact)
-
Current.executed_by = nil
-
I18n.t('conversations.activity.status.system_auto_open')
-
end
-
end
-
-
1
def activity_message_params(content)
-
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
-
end
-
-
1
def create_muted_message
-
create_mute_change_activity('muted')
-
end
-
-
1
def create_unmuted_message
-
create_mute_change_activity('unmuted')
-
end
-
-
1
def create_mute_change_activity(change_type)
-
return unless Current.user
-
-
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
-
1
def generate_assignee_change_activity_content(user_name)
-
params = { assignee_name: assignee&.name, user_name: user_name }.compact
-
key = assignee_id ? 'assigned' : 'removed'
-
key = 'self_assigned' if self_assign? assignee_id
-
I18n.t("conversations.activity.assignee.#{key}", **params)
-
end
-
-
1
def create_assignee_change_activity(user_name)
-
user_name = activity_message_owner(user_name)
-
-
return unless user_name
-
-
content = generate_assignee_change_activity_content(user_name)
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
-
1
def activity_message_owner(user_name)
-
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
-
user_name
-
end
-
end
-
1
module AssignmentHandler
-
1
extend ActiveSupport::Concern
-
1
include Events::Types
-
-
1
included do
-
1
before_save :ensure_assignee_is_from_team
-
1
after_commit :notify_assignment_change, :process_assignment_changes
-
end
-
-
1
private
-
-
1
def ensure_assignee_is_from_team
-
6
return unless team_id_changed?
-
-
validate_current_assignee_team
-
self.assignee ||= find_assignee_from_team
-
end
-
-
1
def validate_current_assignee_team
-
self.assignee_id = nil if team&.members&.exclude?(assignee)
-
end
-
-
1
def find_assignee_from_team
-
return if team&.allow_auto_assign.blank?
-
-
team_members_with_capacity = inbox.member_ids_with_assignment_capacity & team.members.ids
-
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: team_members_with_capacity).find_assignee
-
end
-
-
1
def notify_assignment_change
-
{
-
18
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? },
-
9
TEAM_CHANGED => -> { saved_change_to_team_id? }
-
}.each do |event, condition|
-
18
condition.call && dispatcher_dispatch(event, previous_changes)
-
end
-
end
-
-
1
def process_assignment_changes
-
9
process_assignment_activities
-
end
-
-
1
def process_assignment_activities
-
9
user_name = Current.user.name if Current.user.present?
-
9
if saved_change_to_team_id?
-
create_team_change_activity(user_name)
-
9
elsif saved_change_to_assignee_id?
-
create_assignee_change_activity(user_name)
-
end
-
end
-
-
1
def self_assign?(assignee_id)
-
assignee_id.present? && Current.user&.id == assignee_id
-
end
-
end
-
1
module AutoAssignmentHandler
-
1
extend ActiveSupport::Concern
-
1
include Events::Types
-
-
1
included do
-
1
after_save :run_auto_assignment
-
end
-
-
1
private
-
-
1
def run_auto_assignment
-
# Round robin kicks in on conversation create & update
-
# run it only when conversation status changes to open
-
6
return unless conversation_status_changed_to_open?
-
3
return unless should_run_auto_assignment?
-
-
3
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
-
end
-
-
1
def should_run_auto_assignment?
-
3
return false unless inbox.enable_auto_assignment?
-
-
# run only if assignee is blank or doesn't have access to inbox
-
3
assignee.blank? || inbox.members.exclude?(assignee)
-
end
-
end
-
1
module AvailabilityStatusable
-
1
extend ActiveSupport::Concern
-
-
1
def online_presence?
-
obj_id = is_a?(Contact) ? id : user_id
-
::OnlineStatusTracker.get_presence(account_id, self.class.name, obj_id)
-
end
-
-
1
def availability_status
-
if is_a? Contact
-
contact_availability_status
-
else
-
user_availability_status
-
end
-
end
-
-
1
private
-
-
1
def contact_availability_status
-
online_presence? ? 'online' : 'offline'
-
end
-
-
1
def user_availability_status
-
# we are not considering presence in this case. Just returns the availability
-
return availability unless auto_offline
-
-
# availability as a fallback in case the status is not present in redis
-
online_presence? ? (::OnlineStatusTracker.get_status(account_id, user_id) || availability) : 'offline'
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Avatarable
-
1
extend ActiveSupport::Concern
-
1
include Rails.application.routes.url_helpers
-
-
1
included do
-
4
has_one_attached :avatar
-
28
validate :acceptable_avatar, if: -> { avatar.changed? }
-
4
after_save :fetch_avatar_from_gravatar
-
end
-
-
1
def avatar_url
-
48
return url_for(avatar.representation(resize_to_fill: [250, nil])) if avatar.attached? && avatar.representable?
-
-
48
''
-
end
-
-
1
def fetch_avatar_from_gravatar
-
24
return unless saved_changes.key?(:email)
-
6
return if email.blank?
-
-
# Incase avatar_url is supplied, we don't want to fetch avatar from gravatar
-
# So we will wait for it to be processed
-
6
Avatar::AvatarFromGravatarJob.set(wait: 30.seconds).perform_later(self, email)
-
end
-
-
1
def acceptable_avatar
-
return unless avatar.attached?
-
-
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
-
-
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
-
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
-
end
-
end
-
1
module CacheKeys
-
1
extend ActiveSupport::Concern
-
-
1
include CacheKeysHelper
-
1
include Events::Types
-
-
1
CACHE_KEYS_EXPIRY = 72.hours
-
-
1
included do
-
1
class_attribute :cacheable_models
-
1
self.cacheable_models = [Label, Inbox, Team]
-
end
-
-
1
def cache_keys
-
9
keys = {}
-
9
self.class.cacheable_models.each do |model|
-
27
keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
-
end
-
-
9
keys
-
end
-
-
1
def update_cache_key(key)
-
9
update_cache_key_for_account(id, key)
-
9
dispatch_cache_update_event
-
end
-
-
1
def reset_cache_keys
-
self.class.cacheable_models.each do |model|
-
update_cache_key_for_account(id, model.name.underscore)
-
end
-
-
dispatch_cache_update_event
-
end
-
-
1
private
-
-
1
def update_cache_key_for_account(account_id, key)
-
9
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
-
9
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY)
-
end
-
-
1
def dispatch_cache_update_event
-
9
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
-
end
-
end
-
1
module Channelable
-
1
extend ActiveSupport::Concern
-
1
included do
-
1
validates :account_id, presence: true
-
1
belongs_to :account
-
1
has_one :inbox, as: :channel, dependent: :destroy_async, touch: true
-
1
after_update :create_audit_log_entry
-
end
-
-
1
def create_audit_log_entry; end
-
end
-
-
1
Channelable.prepend_mod_with('Channelable')
-
1
class ContentAttributeValidator < ActiveModel::Validator
-
1
ALLOWED_SELECT_ITEM_KEYS = [:title, :value].freeze
-
1
ALLOWED_CARD_ITEM_KEYS = [:title, :description, :media_url, :actions].freeze
-
1
ALLOWED_CARD_ITEM_ACTION_KEYS = [:text, :type, :payload, :uri].freeze
-
1
ALLOWED_FORM_ITEM_KEYS = [:type, :placeholder, :label, :name, :options, :default, :required, :pattern, :title, :pattern_error].freeze
-
1
ALLOWED_ARTICLE_KEYS = [:title, :description, :link].freeze
-
-
1
def validate(record)
-
6
case record.content_type
-
when 'input_select'
-
validate_items!(record)
-
validate_item_attributes!(record, ALLOWED_SELECT_ITEM_KEYS)
-
when 'cards'
-
validate_items!(record)
-
validate_item_attributes!(record, ALLOWED_CARD_ITEM_KEYS)
-
validate_item_actions!(record)
-
when 'form'
-
validate_items!(record)
-
validate_item_attributes!(record, ALLOWED_FORM_ITEM_KEYS)
-
when 'article'
-
validate_items!(record)
-
validate_item_attributes!(record, ALLOWED_ARTICLE_KEYS)
-
end
-
end
-
-
1
private
-
-
1
def validate_items!(record)
-
record.errors.add(:content_attributes, 'At least one item is required.') if record.items.blank?
-
record.errors.add(:content_attributes, 'Items should be a hash.') if record.items.reject { |item| item.is_a?(Hash) }.present?
-
end
-
-
1
def validate_item_attributes!(record, valid_keys)
-
item_keys = record.items.collect(&:keys).flatten.filter_map(&:to_sym)
-
invalid_keys = item_keys - valid_keys
-
record.errors.add(:content_attributes, "contains invalid keys for items : #{invalid_keys}") if invalid_keys.present?
-
end
-
-
1
def validate_item_actions!(record)
-
if record.items.select { |item| item[:actions].blank? }.present?
-
record.errors.add(:content_attributes, 'contains items missing actions') && return
-
end
-
-
validate_item_action_attributes!(record)
-
end
-
-
1
def validate_item_action_attributes!(record)
-
item_action_keys = record.items.collect { |item| item[:actions].collect(&:keys) }
-
invalid_keys = item_action_keys.flatten.compact.map(&:to_sym) - ALLOWED_CARD_ITEM_ACTION_KEYS
-
record.errors.add(:content_attributes, "contains invalid keys for actions: #{invalid_keys}") if invalid_keys.present?
-
end
-
end
-
1
module ConversationMuteHelpers
-
1
extend ActiveSupport::Concern
-
-
1
def mute!
-
resolved!
-
contact.update(blocked: true)
-
create_muted_message
-
end
-
-
1
def unmute!
-
contact.update(blocked: false)
-
create_unmuted_message
-
end
-
-
1
def muted?
-
6
contact.blocked?
-
end
-
end
-
1
module Featurable
-
1
extend ActiveSupport::Concern
-
-
1
QUERY_MODE = {
-
flag_query_mode: :bit_operator,
-
check_for_column: false
-
}.freeze
-
-
1
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
-
-
1
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
-
44
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
-
end
-
-
1
included do
-
1
include FlagShihTzu
-
1
has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE)
-
-
1
before_create :enable_default_features
-
end
-
-
1
def enable_features(*names)
-
6
names.each do |name|
-
144
send("feature_#{name}=", true)
-
end
-
end
-
-
1
def enable_features!(*names)
-
enable_features(*names)
-
save
-
end
-
-
1
def disable_features(*names)
-
names.each do |name|
-
send("feature_#{name}=", false)
-
end
-
end
-
-
1
def disable_features!(*names)
-
disable_features(*names)
-
save
-
end
-
-
1
def feature_enabled?(name)
-
18
send("feature_#{name}?")
-
end
-
-
1
def all_features
-
FEATURE_LIST.pluck('name').index_with do |feature_name|
-
feature_enabled?(feature_name)
-
end
-
end
-
-
1
def enabled_features
-
all_features.select { |_feature, enabled| enabled == true }
-
end
-
-
1
def disabled_features
-
all_features.select { |_feature, enabled| enabled == false }
-
end
-
-
1
private
-
-
1
def enable_default_features
-
6
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
-
6
return true if config.blank?
-
-
270
features_to_enabled = config.value.select { |f| f[:enabled] }.pluck(:name)
-
6
enable_features(*features_to_enabled)
-
end
-
end
-
# This file defines a custom validator class `JsonSchemaValidator` for validating a JSON object against a schema.
-
# To use this validator, define a schema as a Ruby hash and include it in the validation options when validating a model.
-
# The schema should define the expected structure and types of the JSON object, as well as any validation rules.
-
# Here's an example schema:
-
#
-
# schema = {
-
# 'type' => 'object',
-
# 'properties' => {
-
# 'name' => { 'type' => 'string' },
-
# 'age' => { 'type' => 'integer' },
-
# 'is_active' => { 'type' => 'boolean' },
-
# 'tags' => { 'type' => 'array' },
-
# 'address' => {
-
# 'type' => 'object',
-
# 'properties' => {
-
# 'street' => { 'type' => 'string' },
-
# 'city' => { 'type' => 'string' }
-
# },
-
# 'required' => ['street', 'city']
-
# }
-
# },
-
# 'required': ['name', 'age']
-
# }.to_json.freeze
-
#
-
# To validate a model using this schema, include the `JsonSchemaValidator` in the model's validations and pass the schema
-
# as an option:
-
#
-
# class MyModel < ApplicationRecord
-
# validates_with JsonSchemaValidator, schema: schema
-
# end
-
-
1
class JsonSchemaValidator < ActiveModel::Validator
-
1
def validate(record)
-
# Get the attribute resolver function from options or use a default one
-
12
attribute_resolver = options[:attribute_resolver] || ->(rec) { rec.additional_attributes }
-
-
# Resolve the JSON data to be validated
-
12
json_data = attribute_resolver.call(record)
-
-
# Get the schema to be used for validation
-
12
schema = options[:schema]
-
-
# Create a JSONSchemer instance using the schema
-
12
schemer = JSONSchemer.schema(schema)
-
-
# Validate the JSON data against the schema
-
12
validation_errors = schemer.validate(json_data)
-
-
# Add validation errors to the record with a formatted statement
-
12
validation_errors.each do |error|
-
format_and_append_error(error, record)
-
end
-
end
-
-
1
private
-
-
1
def format_and_append_error(error, record)
-
return handle_required(error, record) if error['type'] == 'required'
-
return handle_minimum(error, record) if error['type'] == 'minimum'
-
return handle_maximum(error, record) if error['type'] == 'maximum'
-
-
type = error['type'] == 'object' ? 'hash' : error['type']
-
-
handle_type(error, record, type)
-
end
-
-
1
def handle_required(error, record)
-
missing_values = error['details']['missing_keys']
-
missing_values.each do |missing|
-
record.errors.add(missing, 'is required')
-
end
-
end
-
-
1
def handle_type(error, record, expected_type)
-
data = get_name_from_data_pointer(error)
-
record.errors.add(data, "must be of type #{expected_type}")
-
end
-
-
1
def handle_minimum(error, record)
-
data = get_name_from_data_pointer(error)
-
record.errors.add(data, "must be greater than or equal to #{error['schema']['minimum']}")
-
end
-
-
1
def handle_maximum(error, record)
-
data = get_name_from_data_pointer(error)
-
record.errors.add(data, "must be less than or equal to #{error['schema']['maximum']}")
-
end
-
-
1
def get_name_from_data_pointer(error)
-
data = error['data_pointer']
-
-
# if data starts with a "/" remove it
-
data[1..] if data[0] == '/'
-
end
-
end
-
1
module LabelActivityMessageHandler
-
1
extend ActiveSupport::Concern
-
-
1
private
-
-
1
def create_label_added(user_name, labels = [])
-
create_label_change_activity('added', user_name, labels)
-
end
-
-
1
def create_label_removed(user_name, labels = [])
-
create_label_change_activity('removed', user_name, labels)
-
end
-
-
1
def create_label_change_activity(change_type, user_name, labels = [])
-
return unless labels.size.positive?
-
-
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
end
-
1
module Labelable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
2
acts_as_taggable_on :labels
-
end
-
-
1
def update_labels(labels = nil)
-
update!(label_list: labels)
-
end
-
-
1
def add_labels(new_labels = nil)
-
new_labels = Array(new_labels) # Make sure new_labels is an array
-
combined_labels = labels + new_labels
-
update!(label_list: combined_labels)
-
end
-
end
-
1
module Liquidable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
before_create :process_liquid_in_content
-
end
-
-
1
private
-
-
1
def message_drops
-
{
-
3
'contact' => ContactDrop.new(conversation.contact),
-
'agent' => UserDrop.new(sender),
-
'conversation' => ConversationDrop.new(conversation),
-
'inbox' => InboxDrop.new(inbox),
-
'account' => AccountDrop.new(conversation.account)
-
}
-
end
-
-
1
def liquid_processable_message?
-
6
content.present? && (message_type == 'outgoing' || message_type == 'template')
-
end
-
-
1
def process_liquid_in_content
-
6
return unless liquid_processable_message?
-
-
3
template = Liquid::Template.parse(modified_liquid_content)
-
3
self.content = template.render(message_drops)
-
rescue Liquid::Error
-
# If there is an error in the liquid syntax, we don't want to process it
-
end
-
-
1
def modified_liquid_content
-
# This regex is used to match the code blocks in the content
-
# We don't want to process liquid in code blocks
-
3
content.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
-
end
-
end
-
1
module LlmFormattable
-
1
extend ActiveSupport::Concern
-
-
1
def to_llm_text
-
LlmFormatter::LlmTextFormatterService.new(self).format
-
end
-
end
-
1
module MessageFilterHelpers
-
1
extend ActiveSupport::Concern
-
-
1
def reportable?
-
incoming? || outgoing?
-
end
-
-
1
def webhook_sendable?
-
incoming? || outgoing? || template?
-
end
-
-
1
def slack_hook_sendable?
-
incoming? || outgoing? || template?
-
end
-
-
1
def notifiable?
-
incoming? || outgoing?
-
end
-
-
1
def conversation_transcriptable?
-
incoming? || outgoing?
-
end
-
-
1
def email_reply_summarizable?
-
incoming? || outgoing? || input_csat?
-
end
-
-
1
def instagram_story_mention?
-
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module OutOfOffisable
-
1
extend ActiveSupport::Concern
-
-
1
OFFISABLE_ATTRS = %w[day_of_week closed_all_day open_hour open_minutes close_hour close_minutes open_all_day].freeze
-
-
1
included do
-
1
has_many :working_hours, dependent: :destroy_async
-
1
after_create :create_default_working_hours
-
end
-
-
1
def out_of_office?
-
3
working_hours_enabled? && working_hours.today.closed_now?
-
end
-
-
1
def working_now?
-
!out_of_office?
-
end
-
-
1
def weekly_schedule
-
working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
-
end
-
-
# accepts an array of hashes similiar to the format of weekly_schedule
-
# [
-
# { "day_of_week"=>1,
-
# "closed_all_day"=>false,
-
# "open_hour"=>9,
-
# "open_minutes"=>0,
-
# "close_hour"=>17,
-
# "close_minutes"=>0,
-
# "open_all_day=>false" },...]
-
1
def update_working_hours(params)
-
ActiveRecord::Base.transaction do
-
params.each do |working_hour|
-
working_hours.find_by(day_of_week: working_hour['day_of_week']).update(working_hour.slice(*OFFISABLE_ATTRS))
-
end
-
end
-
end
-
-
1
private
-
-
1
def create_default_working_hours
-
9
working_hours.create!(day_of_week: 0, closed_all_day: true, open_all_day: false)
-
9
working_hours.create!(day_of_week: 1, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
-
9
working_hours.create!(day_of_week: 2, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
-
9
working_hours.create!(day_of_week: 3, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
-
9
working_hours.create!(day_of_week: 4, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
-
9
working_hours.create!(day_of_week: 5, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
-
9
working_hours.create!(day_of_week: 6, closed_all_day: true, open_all_day: false)
-
end
-
end
-
1
module PriorityActivityMessageHandler
-
1
extend ActiveSupport::Concern
-
-
1
private
-
-
1
def priority_change_activity(user_name)
-
old_priority, new_priority = previous_changes.values_at('priority')[0]
-
return unless priority_change?(old_priority, new_priority)
-
-
user = Current.executed_by.instance_of?(AutomationRule) ? I18n.t('automation.system_name') : user_name
-
content = build_priority_change_content(user, old_priority, new_priority)
-
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
-
1
def priority_change?(old_priority, new_priority)
-
old_priority.present? || new_priority.present?
-
end
-
-
1
def build_priority_change_content(user_name, old_priority = nil, new_priority = nil)
-
change_type = get_priority_change_type(old_priority, new_priority)
-
-
I18n.t("conversations.activity.priority.#{change_type}", user_name: user_name, new_priority: new_priority, old_priority: old_priority)
-
end
-
-
1
def get_priority_change_type(old_priority, new_priority)
-
case [old_priority.present?, new_priority.present?]
-
when [true, true] then 'updated'
-
when [false, true] then 'added'
-
when [true, false] then 'removed'
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Pubsubable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
# Used by the actionCable/PubSub Service we use for real time communications
-
2
has_secure_token :pubsub_token
-
2
before_save :rotate_pubsub_token
-
end
-
-
1
def rotate_pubsub_token
-
# ATM we are only rotating the token if the user is changing their password
-
9
return unless is_a?(User)
-
-
# Using the class method to avoid the extra Save
-
# TODO: Should we do this on signin ?
-
6
self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
-
end
-
-
1
def pubsub_token
-
# backfills tokens for existing records
-
21
regenerate_pubsub_token if self[:pubsub_token].blank? && persisted?
-
21
self[:pubsub_token]
-
end
-
end
-
1
module PushDataHelper
-
1
extend ActiveSupport::Concern
-
-
1
def push_event_data
-
12
Conversations::EventDataPresenter.new(self).push_data
-
end
-
-
1
def lock_event_data
-
Conversations::EventDataPresenter.new(self).lock_data
-
end
-
-
1
def webhook_data
-
Conversations::EventDataPresenter.new(self).push_data
-
end
-
end
-
# This concern is primarily targeted for business models dependent on external services
-
# The auth tokens we obtained on their behalf could expire or becomes invalid.
-
# We would be aware of it until we make the API call to the service and it throws error
-
-
# Example:
-
# when a user changes his/her password, the auth token they provided to chatwoot becomes invalid
-
-
# This module helps to capture the errors into a counter and when threshold is passed would mark
-
# the object to be reauthorized. We will also send an email to the owners alerting them of the error.
-
-
# In the UI, we will check for the reauthorization_required? status and prompt the reauthorization flow
-
-
1
module Reauthorizable
-
1
extend ActiveSupport::Concern
-
-
1
AUTHORIZATION_ERROR_THRESHOLD = 2
-
-
# model attribute
-
1
def reauthorization_required?
-
::Redis::Alfred.get(reauthorization_required_key).present?
-
end
-
-
# model attribute
-
1
def authorization_error_count
-
::Redis::Alfred.get(authorization_error_count_key).to_i
-
end
-
-
# action to be performed when we receive authorization errors
-
# Implement in your exception handling logic for authorization errors
-
1
def authorization_error!
-
::Redis::Alfred.incr(authorization_error_count_key)
-
# we are giving precendence to the authorization error threshhold defined in the class
-
# so that channels can override the default value
-
prompt_reauthorization! if authorization_error_count >= self.class::AUTHORIZATION_ERROR_THRESHOLD
-
end
-
-
# Performed automatically if error threshold is breached
-
# could used to manually prompt reauthorization if auth scope changes
-
1
def prompt_reauthorization!
-
::Redis::Alfred.set(reauthorization_required_key, true)
-
-
reauthorization_handlers[self.class.name]&.call(self)
-
-
invalidate_inbox_cache unless instance_of?(::AutomationRule)
-
end
-
-
1
def process_integration_hook_reauthorization_emails
-
if slack?
-
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
-
elsif dialogflow?
-
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
-
end
-
end
-
-
1
def send_channel_reauthorization_email(disconnect_type)
-
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).public_send(disconnect_type, inbox).deliver_later
-
end
-
-
1
def handle_automation_rule_reauthorization
-
update!(active: false)
-
AdministratorNotifications::AccountNotificationMailer.with(account: account).automation_rule_disabled(self).deliver_later
-
end
-
-
# call this after you successfully Reauthorized the object in UI
-
1
def reauthorized!
-
::Redis::Alfred.delete(authorization_error_count_key)
-
::Redis::Alfred.delete(reauthorization_required_key)
-
-
invalidate_inbox_cache unless instance_of?(::AutomationRule)
-
end
-
-
1
private
-
-
1
def reauthorization_handlers
-
{
-
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
-
'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) },
-
'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) },
-
'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) },
-
'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) },
-
'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization }
-
}
-
end
-
-
1
def invalidate_inbox_cache
-
inbox.update_account_cache if inbox.present?
-
end
-
-
1
def authorization_error_count_key
-
format(::Redis::Alfred::AUTHORIZATION_ERROR_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
-
end
-
-
1
def reauthorization_required_key
-
format(::Redis::Alfred::REAUTHORIZATION_REQUIRED, obj_type: self.class.table_name.singularize, obj_id: id)
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Reportable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
3
has_many :reporting_events, dependent: :destroy
-
end
-
end
-
1
module SlaActivityMessageHandler
-
1
extend ActiveSupport::Concern
-
-
1
private
-
-
1
def create_sla_change_activity(change_type, user_name)
-
content = case change_type
-
when 'added'
-
I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name)
-
when 'removed'
-
I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name)
-
when 'updated'
-
I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name)
-
end
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
-
1
def sla_policy_name
-
SlaPolicy.find_by(id: sla_policy_id)&.name || ''
-
end
-
-
1
def determine_sla_change_type
-
sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id]
-
-
if sla_policy_id_before.nil? && sla_policy_id_after.present?
-
'added'
-
elsif sla_policy_id_before.present? && sla_policy_id_after.nil?
-
'removed'
-
end
-
end
-
end
-
1
module SortHandler
-
1
extend ActiveSupport::Concern
-
-
1
class_methods do
-
1
def sort_on_last_activity_at(sort_direction = :desc)
-
order(last_activity_at: sort_direction)
-
end
-
-
1
def sort_on_created_at(sort_direction = :asc)
-
order(created_at: sort_direction)
-
end
-
-
1
def sort_on_priority(sort_direction = :desc)
-
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, last_activity_at DESC"))
-
end
-
-
1
def sort_on_waiting_since(sort_direction = :asc)
-
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
-
end
-
-
1
def last_messaged_conversations
-
Message.except(:order).select(
-
'DISTINCT ON (conversation_id) conversation_id, id, created_at, message_type'
-
).order('conversation_id, created_at DESC')
-
end
-
-
1
def sort_on_last_user_message_at
-
order('grouped_conversations.message_type', 'grouped_conversations.created_at ASC')
-
end
-
-
1
private
-
-
1
def generate_sql_query(query)
-
Arel::Nodes::SqlLiteral.new(sanitize_sql_for_order(query))
-
end
-
end
-
end
-
1
module SsoAuthenticatable
-
1
extend ActiveSupport::Concern
-
-
1
def generate_sso_auth_token
-
token = SecureRandom.hex(32)
-
::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes)
-
token
-
end
-
-
1
def invalidate_sso_auth_token(token)
-
::Redis::Alfred.delete(sso_token_key(token))
-
end
-
-
1
def valid_sso_auth_token?(token)
-
::Redis::Alfred.get(sso_token_key(token)).present?
-
end
-
-
1
def generate_sso_link
-
encoded_email = ERB::Util.url_encode(email)
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
-
end
-
-
1
private
-
-
1
def sso_token_key(token)
-
format(::Redis::RedisKeys::USER_SSO_AUTH_TOKEN, user_id: id, token: token)
-
end
-
end
-
1
module TeamActivityMessageHandler
-
1
extend ActiveSupport::Concern
-
-
1
private
-
-
1
def create_team_change_activity(user_name)
-
user_name = activity_message_owner(user_name)
-
return unless user_name
-
-
key = generate_team_change_activity_key
-
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
-
params[:team_name] = generate_team_name_for_activity if key == 'removed'
-
content = I18n.t("conversations.activity.team.#{key}", **params)
-
-
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
-
end
-
-
1
def generate_team_change_activity_key
-
team = Team.find_by(id: team_id)
-
key = team.present? ? 'assigned' : 'removed'
-
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
-
key
-
end
-
-
1
def generate_team_name_for_activity
-
previous_team_id = previous_changes[:team_id][0]
-
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
-
end
-
end
-
1
module UserAttributeHelpers
-
1
extend ActiveSupport::Concern
-
-
1
def available_name
-
9
self[:display_name].presence || name
-
end
-
-
1
def availability_status
-
9
current_account_user&.availability_status
-
end
-
-
1
def auto_offline
-
current_account_user&.auto_offline
-
end
-
-
1
def inviter
-
current_account_user&.inviter
-
end
-
-
1
def active_account_user
-
account_users.order(active_at: :desc)&.first
-
end
-
-
1
def current_account_user
-
# We want to avoid subsequent queries in case where the association is preloaded.
-
# using where here will trigger n+1 queries.
-
9
account_users.find { |ac_usr| ac_usr.account_id == Current.account.id } if Current.account
-
end
-
-
1
def account
-
current_account_user&.account
-
end
-
-
1
def administrator?
-
current_account_user&.administrator?
-
end
-
-
1
def agent?
-
current_account_user&.agent?
-
end
-
-
1
def role
-
current_account_user&.role
-
end
-
-
# Used internally for Chatwoot in Chatwoot
-
1
def hmac_identifier
-
hmac_key = GlobalConfig.get('CHATWOOT_INBOX_HMAC_KEY')['CHATWOOT_INBOX_HMAC_KEY']
-
return OpenSSL::HMAC.hexdigest('sha256', hmac_key, email) if hmac_key.present?
-
-
''
-
end
-
end
-
# rubocop:disable Layout/LineLength
-
-
# == Schema Information
-
#
-
# Table name: contacts
-
#
-
# id :integer not null, primary key
-
# additional_attributes :jsonb
-
# blocked :boolean default(FALSE), not null
-
# contact_type :integer default("visitor")
-
# country_code :string default("")
-
# custom_attributes :jsonb
-
# email :string
-
# identifier :string
-
# last_activity_at :datetime
-
# last_name :string default("")
-
# location :string default("")
-
# middle_name :string default("")
-
# name :string default("")
-
# phone_number :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
#
-
# Indexes
-
#
-
# index_contacts_on_account_id (account_id)
-
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
-
# index_contacts_on_blocked (blocked)
-
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
-
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
-
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
-
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
-
# index_resolved_contact_account_id (account_id) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
-
# uniq_email_per_account_contact (email,account_id) UNIQUE
-
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
-
#
-
-
# rubocop:enable Layout/LineLength
-
-
1
class Contact < ApplicationRecord
-
1
include Avatarable
-
1
include AvailabilityStatusable
-
1
include Labelable
-
1
include LlmFormattable
-
-
1
validates :account_id, presence: true
-
1
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
-
format: { with: Devise.email_regexp, message: I18n.t('errors.contacts.email.invalid') }
-
1
validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] }
-
1
validates :phone_number,
-
allow_blank: true, uniqueness: { scope: [:account_id] },
-
format: { with: /\+[1-9]\d{1,14}\z/, message: I18n.t('errors.contacts.phone_number.invalid') }
-
-
1
belongs_to :account
-
1
has_many :conversations, dependent: :destroy_async
-
1
has_many :contact_inboxes, dependent: :destroy_async
-
1
has_many :csat_survey_responses, dependent: :destroy_async
-
1
has_many :inboxes, through: :contact_inboxes
-
1
has_many :messages, as: :sender, dependent: :destroy_async
-
1
has_many :notes, dependent: :destroy_async
-
1
before_validation :prepare_contact_attributes
-
1
after_create_commit :dispatch_create_event, :ip_lookup
-
1
after_update_commit :dispatch_update_event
-
1
after_destroy_commit :dispatch_destroy_event
-
1
before_save :sync_contact_attributes
-
-
1
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
-
-
1
scope :order_on_last_activity_at, lambda { |direction|
-
order(
-
Arel::Nodes::SqlLiteral.new(
-
sanitize_sql_for_order("\"contacts\".\"last_activity_at\" #{direction}
-
NULLS LAST")
-
)
-
)
-
}
-
1
scope :order_on_created_at, lambda { |direction|
-
order(
-
Arel::Nodes::SqlLiteral.new(
-
sanitize_sql_for_order("\"contacts\".\"created_at\" #{direction}
-
NULLS LAST")
-
)
-
)
-
}
-
1
scope :order_on_company_name, lambda { |direction|
-
order(
-
Arel::Nodes::SqlLiteral.new(
-
sanitize_sql_for_order(
-
"\"contacts\".\"additional_attributes\"->>'company_name' #{direction}
-
NULLS LAST"
-
)
-
)
-
)
-
}
-
1
scope :order_on_city, lambda { |direction|
-
order(
-
Arel::Nodes::SqlLiteral.new(
-
sanitize_sql_for_order(
-
"\"contacts\".\"additional_attributes\"->>'city' #{direction}
-
NULLS LAST"
-
)
-
)
-
)
-
}
-
1
scope :order_on_country_name, lambda { |direction|
-
order(
-
Arel::Nodes::SqlLiteral.new(
-
sanitize_sql_for_order(
-
"\"contacts\".\"additional_attributes\"->>'country' #{direction}
-
NULLS LAST"
-
)
-
)
-
)
-
}
-
-
1
scope :order_on_name, lambda { |direction|
-
order(
-
Arel::Nodes::SqlLiteral.new(
-
sanitize_sql_for_order(
-
"CASE
-
WHEN \"contacts\".\"name\" ~~* '^+\d*' THEN 'z'
-
WHEN \"contacts\".\"name\" ~~* '^\b*' THEN 'z'
-
ELSE LOWER(\"contacts\".\"name\")
-
END #{direction}"
-
)
-
)
-
)
-
}
-
-
# Find contacts that:
-
# 1. Have no identification (email, phone_number, and identifier are NULL or empty string)
-
# 2. Have no conversations
-
# 3. Are older than the specified time period
-
1
scope :stale_without_conversations, lambda { |time_period|
-
where('contacts.email IS NULL OR contacts.email = ?', '')
-
.where('contacts.phone_number IS NULL OR contacts.phone_number = ?', '')
-
.where('contacts.identifier IS NULL OR contacts.identifier = ?', '')
-
.where('contacts.created_at < ?', time_period)
-
.where.missing(:conversations)
-
}
-
-
1
def get_source_id(inbox_id)
-
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
-
end
-
-
1
def push_event_data
-
{
-
30
additional_attributes: additional_attributes,
-
custom_attributes: custom_attributes,
-
email: email,
-
id: id,
-
identifier: identifier,
-
name: name,
-
phone_number: phone_number,
-
thumbnail: avatar_url,
-
blocked: blocked,
-
type: 'contact'
-
}
-
end
-
-
1
def webhook_data
-
{
-
account: account.webhook_data,
-
additional_attributes: additional_attributes,
-
avatar: avatar_url,
-
custom_attributes: custom_attributes,
-
email: email,
-
id: id,
-
identifier: identifier,
-
name: name,
-
phone_number: phone_number,
-
thumbnail: avatar_url,
-
blocked: blocked
-
}
-
end
-
-
1
def self.resolved_contacts
-
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
-
end
-
-
1
def discard_invalid_attrs
-
phone_number_format
-
email_format
-
end
-
-
1
def self.from_email(email)
-
find_by(email: email&.downcase)
-
end
-
-
1
private
-
-
1
def ip_lookup
-
6
return unless account.feature_enabled?('ip_lookup')
-
-
ContactIpLookupJob.perform_later(self)
-
end
-
-
1
def phone_number_format
-
return if phone_number.blank?
-
-
self.phone_number = phone_number_was unless phone_number.match?(/\+[1-9]\d{1,14}\z/)
-
end
-
-
1
def email_format
-
return if email.blank?
-
-
self.email = email_was unless email.match(Devise.email_regexp)
-
end
-
-
1
def prepare_contact_attributes
-
9
prepare_email_attribute
-
9
prepare_jsonb_attributes
-
end
-
-
1
def prepare_email_attribute
-
# So that the db unique constraint won't throw error when email is ''
-
9
self.email = email.present? ? email.downcase : nil
-
end
-
-
1
def prepare_jsonb_attributes
-
9
self.additional_attributes = {} if additional_attributes.blank?
-
9
self.custom_attributes = {} if custom_attributes.blank?
-
end
-
-
1
def sync_contact_attributes
-
9
::Contacts::SyncAttributes.new(self).perform
-
end
-
-
1
def dispatch_create_event
-
6
Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self)
-
end
-
-
1
def dispatch_update_event
-
3
Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self, changed_attributes: previous_changes)
-
end
-
-
1
def dispatch_destroy_event
-
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: contact_inboxes
-
#
-
# id :bigint not null, primary key
-
# hmac_verified :boolean default(FALSE)
-
# pubsub_token :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# contact_id :bigint
-
# inbox_id :bigint
-
# source_id :string not null
-
#
-
# Indexes
-
#
-
# index_contact_inboxes_on_contact_id (contact_id)
-
# index_contact_inboxes_on_inbox_id (inbox_id)
-
# index_contact_inboxes_on_inbox_id_and_source_id (inbox_id,source_id) UNIQUE
-
# index_contact_inboxes_on_pubsub_token (pubsub_token) UNIQUE
-
# index_contact_inboxes_on_source_id (source_id)
-
#
-
-
1
class ContactInbox < ApplicationRecord
-
1
include Pubsubable
-
1
include RegexHelper
-
1
validates :inbox_id, presence: true
-
1
validates :contact_id, presence: true
-
1
validates :source_id, presence: true
-
1
validate :valid_source_id_format?
-
-
1
belongs_to :contact
-
1
belongs_to :inbox
-
-
1
has_many :conversations, dependent: :destroy_async
-
-
# contact_inboxes that are not associated with any conversation
-
1
scope :stale_without_conversations, lambda { |time_period|
-
left_joins(:conversations)
-
.where('contact_inboxes.created_at < ?', time_period)
-
.where(conversations: { contact_id: nil })
-
}
-
-
1
def webhook_data
-
{
-
id: id,
-
contact: contact.try(:webhook_data),
-
inbox: inbox.webhook_data,
-
account: inbox.account.webhook_data,
-
current_conversation: current_conversation.try(:webhook_data),
-
source_id: source_id
-
}
-
end
-
-
1
def current_conversation
-
conversations.last
-
end
-
-
1
private
-
-
1
def validate_twilio_source_id
-
# https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164
-
if inbox.channel.medium == 'sms' && !TWILIO_CHANNEL_SMS_REGEX.match?(source_id)
-
errors.add(:source_id, "invalid source id for twilio sms inbox. valid Regex #{TWILIO_CHANNEL_SMS_REGEX}")
-
elsif inbox.channel.medium == 'whatsapp' && !TWILIO_CHANNEL_WHATSAPP_REGEX.match?(source_id)
-
errors.add(:source_id, "invalid source id for twilio whatsapp inbox. valid Regex #{TWILIO_CHANNEL_WHATSAPP_REGEX}")
-
end
-
end
-
-
1
def validate_whatsapp_source_id
-
return if WHATSAPP_CHANNEL_REGEX.match?(source_id)
-
-
errors.add(:source_id, "invalid source id for whatsapp inbox. valid Regex #{WHATSAPP_CHANNEL_REGEX}")
-
end
-
-
1
def valid_source_id_format?
-
3
validate_twilio_source_id if inbox.channel_type == 'Channel::TwilioSms'
-
3
validate_whatsapp_source_id if inbox.channel_type == 'Channel::Whatsapp'
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: conversations
-
#
-
# id :integer not null, primary key
-
# additional_attributes :jsonb
-
# agent_last_seen_at :datetime
-
# assignee_last_seen_at :datetime
-
# cached_label_list :text
-
# contact_last_seen_at :datetime
-
# custom_attributes :jsonb
-
# first_reply_created_at :datetime
-
# identifier :string
-
# last_activity_at :datetime not null
-
# priority :integer
-
# snoozed_until :datetime
-
# status :integer default("open"), not null
-
# uuid :uuid not null
-
# waiting_since :datetime
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# assignee_id :integer
-
# campaign_id :bigint
-
# contact_id :bigint
-
# contact_inbox_id :bigint
-
# display_id :integer not null
-
# inbox_id :integer not null
-
# sla_policy_id :bigint
-
# team_id :bigint
-
#
-
# Indexes
-
#
-
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
-
# index_conversations_on_account_id (account_id)
-
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
-
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
-
# index_conversations_on_campaign_id (campaign_id)
-
# index_conversations_on_contact_id (contact_id)
-
# index_conversations_on_contact_inbox_id (contact_inbox_id)
-
# index_conversations_on_first_reply_created_at (first_reply_created_at)
-
# index_conversations_on_id_and_account_id (account_id,id)
-
# index_conversations_on_inbox_id (inbox_id)
-
# index_conversations_on_priority (priority)
-
# index_conversations_on_status_and_account_id (status,account_id)
-
# index_conversations_on_status_and_priority (status,priority)
-
# index_conversations_on_team_id (team_id)
-
# index_conversations_on_uuid (uuid) UNIQUE
-
# index_conversations_on_waiting_since (waiting_since)
-
#
-
-
1
class Conversation < ApplicationRecord
-
1
include Labelable
-
1
include LlmFormattable
-
1
include AssignmentHandler
-
1
include AutoAssignmentHandler
-
1
include ActivityMessageHandler
-
1
include UrlHelper
-
1
include SortHandler
-
1
include PushDataHelper
-
1
include ConversationMuteHelpers
-
-
1
validates :account_id, presence: true
-
1
validates :inbox_id, presence: true
-
1
validates :contact_id, presence: true
-
1
before_validation :validate_additional_attributes
-
1
validates :additional_attributes, jsonb_attributes_length: true
-
1
validates :custom_attributes, jsonb_attributes_length: true
-
1
validates :uuid, uniqueness: true
-
1
validate :validate_referer_url
-
-
1
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
-
1
enum priority: { low: 0, medium: 1, high: 2, urgent: 3 }
-
-
1
scope :unassigned, -> { where(assignee_id: nil) }
-
1
scope :assigned, -> { where.not(assignee_id: nil) }
-
1
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
-
1
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
-
1
scope :resolvable, lambda { |auto_resolve_after|
-
return none if auto_resolve_after.to_i.zero?
-
-
open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
-
}
-
-
1
scope :last_user_message_at, lambda {
-
joins(
-
"INNER JOIN (#{last_messaged_conversations.to_sql}) AS grouped_conversations
-
ON grouped_conversations.conversation_id = conversations.id"
-
).sort_on_last_user_message_at
-
}
-
-
1
belongs_to :account
-
1
belongs_to :inbox
-
1
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
-
1
belongs_to :contact
-
1
belongs_to :contact_inbox
-
1
belongs_to :team, optional: true
-
1
belongs_to :campaign, optional: true
-
-
1
has_many :mentions, dependent: :destroy_async
-
1
has_many :messages, dependent: :destroy_async, autosave: true
-
1
has_one :csat_survey_response, dependent: :destroy_async
-
1
has_many :conversation_participants, dependent: :destroy_async
-
1
has_many :notifications, as: :primary_actor, dependent: :destroy_async
-
1
has_many :attachments, through: :messages
-
-
1
before_save :ensure_snooze_until_reset
-
1
before_create :determine_conversation_status
-
1
before_create :ensure_waiting_since
-
-
1
after_update_commit :execute_after_update_commit_callbacks
-
1
after_create_commit :notify_conversation_creation
-
1
after_create_commit :load_attributes_created_by_db_triggers
-
-
1
delegate :auto_resolve_after, to: :account
-
-
1
def can_reply?
-
12
Conversations::MessageWindowService.new(self).can_reply?
-
end
-
-
1
def language
-
additional_attributes&.dig('conversation_language')
-
end
-
-
1
def last_activity_at
-
42
self[:last_activity_at] || created_at
-
end
-
-
1
def last_incoming_message
-
6
messages&.incoming&.last
-
end
-
-
1
def toggle_status
-
# FIXME: implement state machine with aasm
-
self.status = open? ? :resolved : :open
-
self.status = :open if pending? || snoozed?
-
save
-
end
-
-
1
def toggle_priority(priority = nil)
-
self.priority = priority.presence
-
save
-
end
-
-
1
def bot_handoff!
-
open!
-
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
-
end
-
-
1
def unread_messages
-
30
agent_last_seen_at.present? ? messages.created_since(agent_last_seen_at) : messages
-
end
-
-
1
def unread_incoming_messages
-
30
unread_messages.where(account_id: account_id).incoming.last(10)
-
end
-
-
1
def cached_label_list_array
-
(cached_label_list || '').split(',').map(&:strip)
-
end
-
-
1
def notifiable_assignee_change?
-
12
return false unless saved_change_to_assignee_id?
-
return false if assignee_id.blank?
-
return false if self_assign?(assignee_id)
-
-
true
-
end
-
-
1
def tweet?
-
12
inbox.inbox_type == 'Twitter' && additional_attributes['type'] == 'tweet'
-
end
-
-
1
def recent_messages
-
messages.chat.last(5)
-
end
-
-
1
def csat_survey_link
-
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{uuid}"
-
end
-
-
1
def dispatch_conversation_updated_event(previous_changes = nil)
-
6
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
-
end
-
-
1
private
-
-
1
def execute_after_update_commit_callbacks
-
6
notify_status_change
-
6
create_activity
-
6
notify_conversation_updation
-
end
-
-
1
def ensure_snooze_until_reset
-
6
self.snoozed_until = nil unless snoozed?
-
end
-
-
1
def ensure_waiting_since
-
3
self.waiting_since = created_at
-
end
-
-
1
def validate_additional_attributes
-
6
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
-
end
-
-
1
def determine_conversation_status
-
3
self.status = :resolved and return if contact.blocked?
-
-
# Message template hooks aren't executed for conversations from campaigns
-
# So making these conversations open for agent visibility
-
3
return if campaign.present?
-
-
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
-
3
self.status = :pending if inbox.active_bot?
-
end
-
-
1
def notify_conversation_creation
-
3
dispatcher_dispatch(CONVERSATION_CREATED)
-
end
-
-
1
def notify_conversation_updation
-
6
return unless previous_changes.keys.present? && allowed_keys?
-
-
6
dispatch_conversation_updated_event(previous_changes)
-
end
-
-
1
def list_of_keys
-
6
%w[team_id assignee_id status snoozed_until custom_attributes label_list waiting_since first_reply_created_at
-
priority]
-
end
-
-
1
def allowed_keys?
-
(
-
6
previous_changes.keys.intersect?(list_of_keys) ||
-
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
-
)
-
end
-
-
1
def load_attributes_created_by_db_triggers
-
# Display id is set via a trigger in the database
-
# So we need to specifically fetch it after the record is created
-
# We can't use reload because it will clear the previous changes, which we need for the dispatcher
-
3
obj_from_db = self.class.find(id)
-
3
self[:display_id] = obj_from_db[:display_id]
-
3
self[:uuid] = obj_from_db[:uuid]
-
end
-
-
1
def notify_status_change
-
{
-
12
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
-
6
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
-
6
CONVERSATION_STATUS_CHANGED => -> { saved_change_to_status? },
-
6
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
-
6
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
-
}.each do |event, condition|
-
30
condition.call && dispatcher_dispatch(event, status_change)
-
end
-
end
-
-
1
def dispatcher_dispatch(event_name, changed_attributes = nil)
-
12
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
-
changed_attributes: changed_attributes,
-
performed_by: Current.executed_by)
-
end
-
-
1
def conversation_status_changed_to_open?
-
6
return false unless open?
-
# saved_change_to_status? method only works in case of update
-
6
return true if previous_changes.key?(:id) || saved_change_to_status?
-
end
-
-
1
def create_label_change(user_name)
-
return unless user_name
-
-
previous_labels, current_labels = previous_changes[:label_list]
-
return unless (previous_labels.is_a? Array) && (current_labels.is_a? Array)
-
-
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
-
-
create_label_added(user_name, current_labels - previous_labels)
-
create_label_removed(user_name, previous_labels - current_labels)
-
end
-
-
1
def validate_referer_url
-
6
return unless additional_attributes['referer']
-
-
self['additional_attributes']['referer'] = nil unless url_valid?(additional_attributes['referer'])
-
end
-
-
# creating db triggers
-
1
trigger.before(:insert).for_each(:row) do
-
1
"NEW.display_id := nextval('conv_dpid_seq_' || NEW.account_id);"
-
end
-
end
-
-
1
Conversation.include_mod_with('Concerns::Conversation')
-
1
Conversation.prepend_mod_with('Conversation')
-
# == Schema Information
-
#
-
# Table name: conversation_participants
-
#
-
# id :bigint not null, primary key
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# conversation_id :bigint not null
-
# user_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_conversation_participants_on_account_id (account_id)
-
# index_conversation_participants_on_conversation_id (conversation_id)
-
# index_conversation_participants_on_user_id (user_id)
-
# index_conversation_participants_on_user_id_and_conversation_id (user_id,conversation_id) UNIQUE
-
#
-
class ConversationParticipant < ApplicationRecord
-
validates :account_id, presence: true
-
validates :conversation_id, presence: true
-
validates :user_id, presence: true
-
validates :user_id, uniqueness: { scope: [:conversation_id] }
-
validate :ensure_inbox_access
-
-
belongs_to :account
-
belongs_to :conversation
-
belongs_to :user
-
-
before_validation :ensure_account_id
-
-
private
-
-
def ensure_account_id
-
self.account_id = conversation&.account_id
-
end
-
-
def ensure_inbox_access
-
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: csat_survey_responses
-
#
-
# id :bigint not null, primary key
-
# feedback_message :text
-
# rating :integer not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# assigned_agent_id :bigint
-
# contact_id :bigint not null
-
# conversation_id :bigint not null
-
# message_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_csat_survey_responses_on_account_id (account_id)
-
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
-
# index_csat_survey_responses_on_contact_id (contact_id)
-
# index_csat_survey_responses_on_conversation_id (conversation_id)
-
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
-
#
-
class CsatSurveyResponse < ApplicationRecord
-
belongs_to :account
-
belongs_to :conversation
-
belongs_to :contact
-
belongs_to :message
-
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
-
-
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
-
validates :account_id, presence: true
-
validates :contact_id, presence: true
-
validates :conversation_id, presence: true
-
-
scope :filter_by_created_at, ->(range) { where(created_at: range) if range.present? }
-
scope :filter_by_assigned_agent_id, ->(user_ids) { where(assigned_agent_id: user_ids) if user_ids.present? }
-
scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
-
scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
-
# filter by rating value
-
scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
-
end
-
# == Schema Information
-
#
-
# Table name: custom_attribute_definitions
-
#
-
# id :bigint not null, primary key
-
# attribute_description :text
-
# attribute_display_name :string
-
# attribute_display_type :integer default("text")
-
# attribute_key :string
-
# attribute_model :integer default("conversation_attribute")
-
# attribute_values :jsonb
-
# default_value :integer
-
# regex_cue :string
-
# regex_pattern :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint
-
#
-
# Indexes
-
#
-
# attribute_key_model_index (attribute_key,attribute_model,account_id) UNIQUE
-
# index_custom_attribute_definitions_on_account_id (account_id)
-
#
-
class CustomAttributeDefinition < ApplicationRecord
-
STANDARD_ATTRIBUTES = {
-
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
-
last_activity_at],
-
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
-
}.freeze
-
-
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
-
validates :attribute_display_name, presence: true
-
-
validates :attribute_key,
-
presence: true,
-
uniqueness: { scope: [:account_id, :attribute_model] }
-
-
validates :attribute_display_type, presence: true
-
validates :attribute_model, presence: true
-
validate :attribute_must_not_conflict, on: :create
-
-
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
-
enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 }
-
-
belongs_to :account
-
after_update :update_widget_pre_chat_custom_fields
-
after_destroy :sync_widget_pre_chat_custom_fields
-
-
private
-
-
def sync_widget_pre_chat_custom_fields
-
::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
-
end
-
-
def update_widget_pre_chat_custom_fields
-
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
-
end
-
-
def attribute_must_not_conflict
-
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
-
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
-
-
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: custom_filters
-
#
-
# id :bigint not null, primary key
-
# filter_type :integer default("conversation"), not null
-
# name :string not null
-
# query :jsonb not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# user_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_custom_filters_on_account_id (account_id)
-
# index_custom_filters_on_user_id (user_id)
-
#
-
class CustomFilter < ApplicationRecord
-
MAX_FILTER_PER_USER = 50
-
belongs_to :user
-
belongs_to :account
-
-
enum filter_type: { conversation: 0, contact: 1, report: 2 }
-
validate :validate_number_of_filters
-
-
def validate_number_of_filters
-
return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
-
-
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: dashboard_apps
-
#
-
# id :bigint not null, primary key
-
# content :jsonb
-
# title :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# user_id :bigint
-
#
-
# Indexes
-
#
-
# index_dashboard_apps_on_account_id (account_id)
-
# index_dashboard_apps_on_user_id (user_id)
-
#
-
class DashboardApp < ApplicationRecord
-
belongs_to :user
-
belongs_to :account
-
validate :validate_content
-
-
private
-
-
def validate_content
-
has_invalid_data = self[:content].blank? || !self[:content].is_a?(Array)
-
self[:content] = [] if has_invalid_data
-
-
content_schema = {
-
'type' => 'array',
-
'items' => {
-
'type' => 'object',
-
'required' => %w[url type],
-
'properties' => {
-
'type' => { 'enum': ['frame'] },
-
'url' => { '$ref' => '#/definitions/saneUrl' }
-
}
-
},
-
'definitions' => {
-
'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' }
-
},
-
'additionalProperties' => false,
-
'minItems' => 1
-
}
-
errors.add(:content, ': Invalid data') unless JSONSchemer.schema(content_schema.to_json).valid?(self[:content])
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: data_imports
-
#
-
# id :bigint not null, primary key
-
# data_type :string not null
-
# processed_records :integer
-
# processing_errors :text
-
# status :integer default("pending"), not null
-
# total_records :integer
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_data_imports_on_account_id (account_id)
-
#
-
class DataImport < ApplicationRecord
-
belongs_to :account
-
validates :data_type, inclusion: { in: ['contacts'], message: I18n.t('errors.data_import.data_type.invalid') }
-
enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
-
-
has_one_attached :import_file
-
has_one_attached :failed_records
-
-
after_create_commit :process_data_import
-
-
private
-
-
def process_data_import
-
# we wait for the file to be uploaded to the cloud
-
DataImportJob.set(wait: 1.minute).perform_later(self)
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: email_templates
-
#
-
# id :bigint not null, primary key
-
# body :text not null
-
# locale :integer default("en"), not null
-
# name :string not null
-
# template_type :integer default("content")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
#
-
# Indexes
-
#
-
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
-
#
-
class EmailTemplate < ApplicationRecord
-
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
-
enum template_type: { layout: 0, content: 1 }
-
belongs_to :account, optional: true
-
-
validates :name, uniqueness: { scope: :account }
-
-
def self.resolver(options = {})
-
::EmailTemplates::DbResolverService.using self, options
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: folders
-
#
-
# id :bigint not null, primary key
-
# name :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# category_id :integer not null
-
#
-
class Folder < ApplicationRecord
-
belongs_to :account
-
belongs_to :category
-
has_many :articles, dependent: :nullify
-
-
validates :account_id, presence: true
-
validates :category_id, presence: true
-
validates :name, presence: true
-
end
-
# frozen_string_literal: true
-
-
# == Schema Information
-
#
-
# Table name: inboxes
-
#
-
# id :integer not null, primary key
-
# allow_messages_after_resolved :boolean default(TRUE)
-
# auto_assignment_config :jsonb
-
# business_name :string
-
# channel_type :string
-
# csat_survey_enabled :boolean default(FALSE)
-
# email_address :string
-
# enable_auto_assignment :boolean default(TRUE)
-
# enable_email_collect :boolean default(TRUE)
-
# greeting_enabled :boolean default(FALSE)
-
# greeting_message :string
-
# lock_to_single_conversation :boolean default(FALSE), not null
-
# name :string not null
-
# out_of_office_message :string
-
# sender_name_type :integer default("friendly"), not null
-
# timezone :string default("UTC")
-
# working_hours_enabled :boolean default(FALSE)
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# channel_id :integer not null
-
# portal_id :bigint
-
#
-
# Indexes
-
#
-
# index_inboxes_on_account_id (account_id)
-
# index_inboxes_on_channel_id_and_channel_type (channel_id,channel_type)
-
# index_inboxes_on_portal_id (portal_id)
-
#
-
# Foreign Keys
-
#
-
# fk_rails_... (portal_id => portals.id)
-
#
-
-
1
class Inbox < ApplicationRecord
-
1
include Reportable
-
1
include Avatarable
-
1
include OutOfOffisable
-
1
include AccountCacheRevalidator
-
-
# Not allowing characters:
-
1
validates :name, presence: true
-
1
validates :name, if: :check_channel_type?, format: { with: %r{^^\b[^/\\<>@]*\b$}, multiline: true,
-
message: I18n.t('errors.inboxes.validations.name') }
-
1
validates :account_id, presence: true
-
1
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
-
1
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
-
1
validates :greeting_message, length: { maximum: Limits::GREETING_MESSAGE_MAX_LENGTH }
-
1
validate :ensure_valid_max_assignment_limit
-
-
1
belongs_to :account
-
1
belongs_to :portal, optional: true
-
-
1
belongs_to :channel, polymorphic: true, dependent: :destroy
-
-
1
has_many :campaigns, dependent: :destroy_async
-
1
has_many :contact_inboxes, dependent: :destroy_async
-
1
has_many :contacts, through: :contact_inboxes
-
-
1
has_many :inbox_members, dependent: :destroy_async
-
1
has_many :members, through: :inbox_members, source: :user
-
1
has_many :conversations, dependent: :destroy_async
-
1
has_many :messages, dependent: :destroy_async
-
-
1
has_one :agent_bot_inbox, dependent: :destroy_async
-
1
has_one :agent_bot, through: :agent_bot_inbox
-
1
has_many :webhooks, dependent: :destroy_async
-
1
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
-
-
1
enum sender_name_type: { friendly: 0, professional: 1 }
-
-
1
after_destroy :delete_round_robin_agents
-
-
1
after_create_commit :dispatch_create_event
-
1
after_update_commit :dispatch_update_event
-
-
1
scope :order_by_name, -> { order('lower(name) ASC') }
-
-
# Adds multiple members to the inbox
-
# @param user_ids [Array<Integer>] Array of user IDs to add as members
-
# @return [void]
-
1
def add_members(user_ids)
-
inbox_members.create!(user_ids.map { |user_id| { user_id: user_id } })
-
update_account_cache
-
end
-
-
# Removes multiple members from the inbox
-
# @param user_ids [Array<Integer>] Array of user IDs to remove
-
# @return [void]
-
1
def remove_members(user_ids)
-
inbox_members.where(user_id: user_ids).destroy_all
-
update_account_cache
-
end
-
-
1
def sms?
-
channel_type == 'Channel::Sms'
-
end
-
-
1
def facebook?
-
channel_type == 'Channel::FacebookPage'
-
end
-
-
1
def instagram?
-
(facebook? || instagram_direct?) && channel.instagram_id.present?
-
end
-
-
1
def instagram_direct?
-
channel_type == 'Channel::Instagram'
-
end
-
-
1
def web_widget?
-
3
channel_type == 'Channel::WebWidget'
-
end
-
-
1
def api?
-
channel_type == 'Channel::Api'
-
end
-
-
1
def email?
-
channel_type == 'Channel::Email'
-
end
-
-
1
def twilio?
-
channel_type == 'Channel::TwilioSms'
-
end
-
-
1
def twitter?
-
channel_type == 'Channel::TwitterProfile'
-
end
-
-
1
def whatsapp?
-
channel_type == 'Channel::Whatsapp'
-
end
-
-
1
def assignable_agents
-
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
-
end
-
-
1
def active_bot?
-
3
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
-
status: 'enabled').count.positive?
-
end
-
-
1
def inbox_type
-
15
channel.name
-
end
-
-
1
def webhook_data
-
{
-
id: id,
-
name: name
-
}
-
end
-
-
1
def callback_webhook_url
-
case channel_type
-
when 'Channel::TwilioSms'
-
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
-
when 'Channel::Sms'
-
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
-
when 'Channel::Line'
-
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
-
when 'Channel::Whatsapp'
-
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
-
end
-
end
-
-
1
def member_ids_with_assignment_capacity
-
3
members.ids
-
end
-
-
1
private
-
-
1
def dispatch_create_event
-
9
return if ENV['ENABLE_INBOX_EVENTS'].blank?
-
-
Rails.configuration.dispatcher.dispatch(INBOX_CREATED, Time.zone.now, inbox: self)
-
end
-
-
1
def dispatch_update_event
-
return if ENV['ENABLE_INBOX_EVENTS'].blank?
-
-
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: previous_changes)
-
end
-
-
1
def ensure_valid_max_assignment_limit
-
# overridden in enterprise/app/models/enterprise/inbox.rb
-
end
-
-
1
def delete_round_robin_agents
-
::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue
-
end
-
-
1
def check_channel_type?
-
9
['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type)
-
end
-
end
-
-
1
Inbox.prepend_mod_with('Inbox')
-
1
Inbox.include_mod_with('Audit::Inbox')
-
1
Inbox.include_mod_with('Concerns::Inbox')
-
# == Schema Information
-
#
-
# Table name: inbox_members
-
#
-
# id :integer not null, primary key
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inbox_id :integer not null
-
# user_id :integer not null
-
#
-
# Indexes
-
#
-
# index_inbox_members_on_inbox_id (inbox_id)
-
# index_inbox_members_on_inbox_id_and_user_id (inbox_id,user_id) UNIQUE
-
#
-
-
1
class InboxMember < ApplicationRecord
-
1
validates :inbox_id, presence: true
-
1
validates :user_id, presence: true
-
1
validates :user_id, uniqueness: { scope: :inbox_id }
-
-
1
belongs_to :user
-
1
belongs_to :inbox
-
-
1
after_create :add_agent_to_round_robin
-
1
after_destroy :remove_agent_from_round_robin
-
-
1
private
-
-
1
def add_agent_to_round_robin
-
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).add_agent_to_queue(user_id)
-
end
-
-
1
def remove_agent_from_round_robin
-
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
-
end
-
end
-
-
1
InboxMember.include_mod_with('Audit::InboxMember')
-
# == Schema Information
-
#
-
# Table name: installation_configs
-
#
-
# id :bigint not null, primary key
-
# locked :boolean default(TRUE), not null
-
# name :string not null
-
# serialized_value :jsonb not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
# Indexes
-
#
-
# index_installation_configs_on_name (name) UNIQUE
-
# index_installation_configs_on_name_and_created_at (name,created_at) UNIQUE
-
#
-
1
class InstallationConfig < ApplicationRecord
-
# https://stackoverflow.com/questions/72970170/upgrading-to-rails-6-1-6-1-causes-psychdisallowedclass-tried-to-load-unspecif
-
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
-
# FIX ME : fixes breakage of installation config. we need to migrate.
-
# Fix configuration in application.rb
-
1
serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess
-
-
1
before_validation :set_lock
-
1
validates :name, presence: true
-
-
# TODO: Get rid of default scope
-
# https://stackoverflow.com/a/1834250/939299
-
10
default_scope { order(created_at: :desc) }
-
1
scope :editable, -> { where(locked: false) }
-
-
1
after_commit :clear_cache
-
-
1
def value
-
9
serialized_value[:value]
-
end
-
-
1
def value=(value_to_assigned)
-
self.serialized_value = {
-
value: value_to_assigned
-
}.with_indifferent_access
-
end
-
-
1
private
-
-
1
def set_lock
-
self.locked = true if locked.nil?
-
end
-
-
1
def clear_cache
-
GlobalConfig.clear_cache
-
end
-
end
-
1
module Integrations
-
1
def self.table_name_prefix
-
1
'integrations_'
-
end
-
end
-
1
class Integrations::App
-
1
include Linear::IntegrationHelper
-
1
attr_accessor :params
-
-
1
def initialize(params)
-
120
@params = params
-
end
-
-
1
def id
-
120
params[:id]
-
end
-
-
1
def name
-
I18n.t("integration_apps.#{params[:i18n_key]}.name")
-
end
-
-
1
def description
-
I18n.t("integration_apps.#{params[:i18n_key]}.description")
-
end
-
-
1
def short_description
-
I18n.t("integration_apps.#{params[:i18n_key]}.short_description")
-
end
-
-
1
def logo
-
params[:logo]
-
end
-
-
1
def fields
-
params[:fields]
-
end
-
-
# There is no way to get the account_id from the linear callback
-
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
-
1
def encode_state
-
generate_linear_token(Current.account.id)
-
end
-
-
1
def action
-
case params[:id]
-
when 'slack'
-
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
-
when 'linear'
-
build_linear_action
-
else
-
params[:action]
-
end
-
end
-
-
1
def active?(account)
-
case params[:id]
-
when 'slack'
-
ENV['SLACK_CLIENT_SECRET'].present?
-
when 'linear'
-
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
-
when 'shopify'
-
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
-
when 'leadsquared'
-
account.feature_enabled?('crm_integration')
-
else
-
true
-
end
-
end
-
-
1
def build_linear_action
-
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
-
[
-
"#{params[:action]}?response_type=code",
-
"client_id=#{app_id}",
-
"redirect_uri=#{self.class.linear_integration_url}",
-
"state=#{encode_state}",
-
'scope=read,write',
-
'prompt=consent'
-
].join('&')
-
end
-
-
1
def enabled?(account)
-
case params[:id]
-
when 'webhook'
-
account.webhooks.exists?
-
when 'dashboard_apps'
-
account.dashboard_apps.exists?
-
else
-
account.hooks.exists?(app_id: id)
-
end
-
end
-
-
1
def hooks
-
Current.account.hooks.where(app_id: id)
-
end
-
-
1
def self.slack_integration_url
-
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack"
-
end
-
-
1
def self.linear_integration_url
-
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
-
end
-
-
1
class << self
-
1
def apps
-
12
Hashie::Mash.new(APPS_CONFIG)
-
end
-
-
1
def all
-
12
apps.values.each_with_object([]) do |app, result|
-
120
result << new(app)
-
end
-
end
-
-
1
def find(params)
-
132
all.detect { |app| app.id == params[:id] }
-
end
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: integrations_hooks
-
#
-
# id :bigint not null, primary key
-
# access_token :string
-
# hook_type :integer default("account")
-
# settings :jsonb
-
# status :integer default("enabled")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
# app_id :string
-
# inbox_id :integer
-
# reference_id :string
-
#
-
1
class Integrations::Hook < ApplicationRecord
-
1
include Reauthorizable
-
-
1
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
-
1
before_validation :ensure_hook_type
-
1
after_create :trigger_setup_if_crm
-
-
1
validates :account_id, presence: true
-
1
validates :app_id, presence: true
-
4
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
-
1
validate :validate_settings_json_schema
-
1
validate :ensure_feature_enabled
-
4
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
-
-
# TODO: This seems to be only used for slack at the moment
-
# We can add a validator when storing the integration settings and toggle this in future
-
1
enum status: { disabled: 0, enabled: 1 }
-
-
1
belongs_to :account
-
1
belongs_to :inbox, optional: true
-
1
has_secure_token :access_token
-
-
1
enum hook_type: { account: 0, inbox: 1 }
-
-
1
scope :account_hooks, -> { where(hook_type: 'account') }
-
1
scope :inbox_hooks, -> { where(hook_type: 'inbox') }
-
-
1
def app
-
12
@app ||= Integrations::App.find(id: app_id)
-
end
-
-
1
def slack?
-
app_id == 'slack'
-
end
-
-
1
def dialogflow?
-
app_id == 'dialogflow'
-
end
-
-
1
def disable
-
update(status: 'disabled')
-
end
-
-
1
def process_event(event)
-
case app_id
-
when 'openai'
-
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
-
else
-
{ error: 'No processor found' }
-
end
-
end
-
-
1
def feature_allowed?
-
3
return true if app.blank?
-
-
flag = app.params[:feature_flag]
-
return true unless flag
-
-
account.feature_enabled?(flag)
-
end
-
-
1
private
-
-
1
def ensure_feature_enabled
-
3
errors.add(:feature_flag, 'Feature not enabled') unless feature_allowed?
-
end
-
-
1
def ensure_hook_type
-
3
self.hook_type = app.params[:hook_type] if app.present?
-
end
-
-
1
def validate_settings_json_schema
-
3
return if app.blank? || app.params[:settings_json_schema].blank?
-
-
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
-
end
-
-
1
def trigger_setup_if_crm
-
# we need setup services to create data prerequisite to functioning of the integration
-
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
-
# https://apidocs.leadsquared.com/create-new-activity-type-api/
-
3
return unless crm_integration?
-
-
::Crm::SetupJob.perform_later(id)
-
end
-
-
1
def crm_integration?
-
3
%w[leadsquared].include?(app_id)
-
end
-
end
-
1
class JsonbAttributesLengthValidator < ActiveModel::EachValidator
-
1
def validate_each(record, attribute, value)
-
12
return if value.empty?
-
-
@attribute = attribute
-
@record = record
-
-
value.each do |key, attribute_value|
-
validate_keys(key, attribute_value)
-
end
-
end
-
-
1
def validate_keys(key, attribute_value)
-
case attribute_value.class.name
-
when 'String'
-
@record.errors.add @attribute, "#{key} length should be < 1500" if attribute_value.length > 1500
-
when 'Integer'
-
@record.errors.add @attribute, "#{key} value should be < 9999999999" if attribute_value > 9_999_999_999
-
end
-
end
-
end
-
module Kbase
-
def self.table_name_prefix
-
'kbase_'
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: labels
-
#
-
# id :bigint not null, primary key
-
# color :string default("#1f93ff"), not null
-
# description :text
-
# show_on_sidebar :boolean
-
# title :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint
-
#
-
# Indexes
-
#
-
# index_labels_on_account_id (account_id)
-
# index_labels_on_title_and_account_id (title,account_id) UNIQUE
-
#
-
1
class Label < ApplicationRecord
-
1
include RegexHelper
-
1
include AccountCacheRevalidator
-
-
1
belongs_to :account
-
-
1
validates :title,
-
presence: { message: I18n.t('errors.validations.presence') },
-
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
-
uniqueness: { scope: :account_id }
-
-
1
after_update_commit :update_associated_models
-
1
default_scope { order(:title) }
-
-
1
before_validation do
-
self.title = title.downcase if attribute_present?('title')
-
end
-
-
1
def conversations
-
account.conversations.tagged_with(title)
-
end
-
-
1
def messages
-
account.messages.where(conversation_id: conversations.pluck(:id))
-
end
-
-
1
def reporting_events
-
account.reporting_events.where(conversation_id: conversations.pluck(:id))
-
end
-
-
1
private
-
-
1
def update_associated_models
-
return unless title_previously_changed?
-
-
Labels::UpdateJob.perform_later(title, title_previously_was, account_id)
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: macros
-
#
-
# id :bigint not null, primary key
-
# actions :jsonb not null
-
# name :string not null
-
# visibility :integer default("personal")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# created_by_id :bigint
-
# updated_by_id :bigint
-
#
-
# Indexes
-
#
-
# index_macros_on_account_id (account_id)
-
#
-
class Macro < ApplicationRecord
-
include Rails.application.routes.url_helpers
-
-
belongs_to :account
-
belongs_to :created_by,
-
class_name: :User, optional: true, inverse_of: :macros
-
belongs_to :updated_by,
-
class_name: :User, optional: true
-
has_many_attached :files
-
-
enum visibility: { personal: 0, global: 1 }
-
-
validate :json_actions_format
-
-
ACTIONS_ATTRS = %w[send_message add_label assign_team assign_agent mute_conversation change_status remove_label remove_assigned_team
-
resolve_conversation snooze_conversation change_priority send_email_transcript send_attachment
-
add_private_note send_webhook_event].freeze
-
-
def set_visibility(user, params)
-
self.visibility = params[:visibility]
-
self.visibility = :personal if user.agent?
-
end
-
-
def self.with_visibility(user, _params)
-
records = Current.account.macros.global
-
records = records.or(personal.where(created_by_id: user.id, account_id: Current.account.id))
-
records.order(:id)
-
end
-
-
def self.current_page(params)
-
params[:page] || 1
-
end
-
-
def file_base_data
-
files.map do |file|
-
{
-
id: file.id,
-
macro_id: id,
-
file_type: file.content_type,
-
account_id: account_id,
-
file_url: url_for(file),
-
blob_id: file.blob_id,
-
filename: file.filename.to_s
-
}
-
end
-
end
-
-
private
-
-
def json_actions_format
-
return if actions.blank?
-
-
attributes = actions.map { |obj, _| obj['action_name'] }
-
actions = attributes - ACTIONS_ATTRS
-
-
errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any?
-
end
-
end
-
-
Macro.include_mod_with('Audit::Macro')
-
# == Schema Information
-
#
-
# Table name: mentions
-
#
-
# id :bigint not null, primary key
-
# mentioned_at :datetime not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# conversation_id :bigint not null
-
# user_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_mentions_on_account_id (account_id)
-
# index_mentions_on_conversation_id (conversation_id)
-
# index_mentions_on_user_id (user_id)
-
# index_mentions_on_user_id_and_conversation_id (user_id,conversation_id) UNIQUE
-
#
-
class Mention < ApplicationRecord
-
include SortHandler
-
-
before_validation :ensure_account_id
-
validates :mentioned_at, presence: true
-
validates :account_id, presence: true
-
validates :conversation_id, presence: true
-
validates :user_id, presence: true
-
validates :user, uniqueness: { scope: :conversation }
-
-
belongs_to :account
-
belongs_to :conversation
-
belongs_to :user
-
-
after_commit :notify_mentioned_user
-
-
scope :latest, -> { order(mentioned_at: :desc) }
-
-
def self.last_user_message_at
-
# INNER query finds the last message created in the conversation group
-
# The outer query JOINS with the latest created message conversations
-
# Then select only latest incoming message from the conversations which doesn't have last message as outgoing
-
# Order by message created_at
-
Mention.joins(
-
"INNER JOIN (#{last_messaged_conversations.to_sql}) AS grouped_conversations
-
ON grouped_conversations.conversation_id = mentions.conversation_id"
-
).sort_on_last_user_message_at
-
end
-
-
private
-
-
def ensure_account_id
-
self.account_id = conversation&.account_id
-
end
-
-
def notify_mentioned_user
-
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: messages
-
#
-
# id :integer not null, primary key
-
# additional_attributes :jsonb
-
# content :text
-
# content_attributes :json
-
# content_type :integer default("text"), not null
-
# external_source_ids :jsonb
-
# message_type :integer not null
-
# private :boolean default(FALSE), not null
-
# processed_message_content :text
-
# sender_type :string
-
# sentiment :jsonb
-
# status :integer default("sent")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# conversation_id :integer not null
-
# inbox_id :integer not null
-
# sender_id :bigint
-
# source_id :string
-
#
-
# Indexes
-
#
-
# index_messages_on_account_created_type (account_id,created_at,message_type)
-
# index_messages_on_account_id (account_id)
-
# index_messages_on_account_id_and_inbox_id (account_id,inbox_id)
-
# index_messages_on_additional_attributes_campaign_id (((additional_attributes -> 'campaign_id'::text))) USING gin
-
# index_messages_on_content (content) USING gin
-
# index_messages_on_conversation_account_type_created (conversation_id,account_id,message_type,created_at)
-
# index_messages_on_conversation_id (conversation_id)
-
# index_messages_on_created_at (created_at)
-
# index_messages_on_inbox_id (inbox_id)
-
# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
-
# index_messages_on_source_id (source_id)
-
#
-
-
1
class Message < ApplicationRecord
-
1
include MessageFilterHelpers
-
1
include Liquidable
-
1
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
-
-
TEMPLATE_PARAMS_SCHEMA = {
-
1
'type': 'object',
-
'properties': {
-
'template_params': {
-
'type': 'object',
-
'properties': {
-
'name': { 'type': 'string' },
-
'category': { 'type': 'string' },
-
'language': { 'type': 'string' },
-
'namespace': { 'type': 'string' },
-
'processed_params': { 'type': 'object' }
-
},
-
'required': %w[name]
-
}
-
}
-
}.to_json.freeze
-
-
1
before_validation :ensure_content_type
-
1
before_validation :prevent_message_flooding
-
1
before_save :ensure_processed_message_content
-
1
before_save :ensure_in_reply_to
-
-
1
validates :account_id, presence: true
-
1
validates :inbox_id, presence: true
-
1
validates :conversation_id, presence: true
-
1
validates_with ContentAttributeValidator
-
1
validates_with JsonSchemaValidator,
-
schema: TEMPLATE_PARAMS_SCHEMA,
-
6
attribute_resolver: ->(record) { record.additional_attributes }
-
-
1
validates :content_type, presence: true
-
1
validates :content, length: { maximum: 150_000 }
-
1
validates :processed_message_content, length: { maximum: 150_000 }
-
-
# when you have a temperory id in your frontend and want it echoed back via action cable
-
1
attr_accessor :echo_id
-
-
1
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
-
1
enum content_type: {
-
text: 0,
-
input_text: 1,
-
input_textarea: 2,
-
input_email: 3,
-
input_select: 4,
-
cards: 5,
-
form: 6,
-
article: 7,
-
incoming_email: 8,
-
input_csat: 9,
-
integrations: 10,
-
sticker: 11
-
}
-
1
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
-
# [:submitted_email, :items, :submitted_values] : Used for bot message types
-
# [:email] : Used by conversation_continuity incoming email messages
-
# [:in_reply_to] : Used to reply to a particular tweet in threads
-
# [:deleted] : Used to denote whether the message was deleted by the agent
-
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
-
# [:external_error : Can specify if the message creation failed due to an error at external API
-
1
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
-
:external_created_at, :story_sender, :story_id, :external_error,
-
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
-
-
1
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
-
-
31
scope :created_since, ->(datetime) { where('created_at > ?', datetime) }
-
13
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
-
1
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') }
-
1
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
-
-
# TODO: Get rid of default scope
-
# https://stackoverflow.com/a/1834250/939299
-
# if you want to change order, use `reorder`
-
83
default_scope { order(created_at: :asc) }
-
-
1
belongs_to :account
-
1
belongs_to :inbox
-
1
belongs_to :conversation, touch: true
-
1
belongs_to :sender, polymorphic: true, optional: true
-
-
1
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
-
1
has_one :csat_survey_response, dependent: :destroy_async
-
1
has_many :notifications, as: :primary_actor, dependent: :destroy_async
-
-
1
after_create_commit :execute_after_create_commit_callbacks
-
-
1
after_update_commit :dispatch_update_event
-
-
1
def channel_token
-
@token ||= inbox.channel.try(:page_access_token)
-
end
-
-
1
def push_event_data
-
18
data = attributes.symbolize_keys.merge(
-
created_at: created_at.to_i,
-
message_type: message_type_before_type_cast,
-
conversation_id: conversation.display_id,
-
conversation: conversation_push_event_data
-
)
-
18
data[:echo_id] = echo_id if echo_id.present?
-
18
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
-
18
merge_sender_attributes(data)
-
end
-
-
1
def conversation_push_event_data
-
{
-
18
assignee_id: conversation.assignee_id,
-
unread_count: conversation.unread_incoming_messages.count,
-
last_activity_at: conversation.last_activity_at.to_i,
-
contact_inbox: { source_id: conversation.contact_inbox.source_id }
-
}
-
end
-
-
1
def merge_sender_attributes(data)
-
18
data[:sender] = sender.push_event_data if sender && !sender.is_a?(AgentBot)
-
18
data[:sender] = sender.push_event_data(inbox) if sender.is_a?(AgentBot)
-
18
data
-
end
-
-
1
def webhook_data
-
data = {
-
account: account.webhook_data,
-
additional_attributes: additional_attributes,
-
content_attributes: content_attributes,
-
content_type: content_type,
-
content: content,
-
conversation: conversation.webhook_data,
-
created_at: created_at,
-
id: id,
-
inbox: inbox.webhook_data,
-
message_type: message_type,
-
private: private,
-
sender: sender.try(:webhook_data),
-
source_id: source_id
-
}
-
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
-
data
-
end
-
-
1
def content
-
# move this to a presenter
-
41
return self[:content] if !input_csat? || inbox.web_widget?
-
-
I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}")
-
end
-
-
1
def email_notifiable_message?
-
6
return false if private?
-
6
return false if %w[outgoing template].exclude?(message_type)
-
3
return false if template? && %w[input_csat text].exclude?(content_type)
-
-
3
true
-
end
-
-
1
def valid_first_reply?
-
6
return false unless outgoing? && human_response? && !private?
-
3
return false if conversation.first_reply_created_at.present?
-
3
return false if conversation.messages.outgoing
-
.where.not(sender_type: 'AgentBot')
-
.where.not(private: true)
-
.where("(additional_attributes->'campaign_id') is null").count > 1
-
-
3
true
-
end
-
-
1
def save_story_info(story_info)
-
self.content_attributes = content_attributes.merge(
-
{
-
story_id: story_info['id'],
-
story_sender: inbox.channel.instagram_id,
-
story_url: story_info['url']
-
}
-
)
-
save!
-
end
-
-
1
private
-
-
1
def prevent_message_flooding
-
# Added this to cover the validation specs in messages
-
# We can revisit and see if we can remove this later
-
6
return if conversation.blank?
-
-
# there are cases where automations can result in message loops, we need to prevent such cases.
-
6
if conversation.messages.where('created_at >= ?', 1.minute.ago).count >= Limits.conversation_message_per_minute_limit
-
Rails.logger.error "Too many message: Account Id - #{account_id} : Conversation id - #{conversation_id}"
-
errors.add(:base, 'Too many messages')
-
end
-
end
-
-
1
def ensure_processed_message_content
-
6
text_content_quoted = content_attributes.dig(:email, :text_content, :quoted)
-
6
html_content_quoted = content_attributes.dig(:email, :html_content, :quoted)
-
-
6
message_content = text_content_quoted || html_content_quoted || content
-
6
self.processed_message_content = message_content&.truncate(150_000)
-
end
-
-
# fetch the in_reply_to message and set the external id
-
1
def ensure_in_reply_to
-
6
in_reply_to = content_attributes[:in_reply_to]
-
6
in_reply_to_external_id = content_attributes[:in_reply_to_external_id]
-
-
6
Messages::InReplyToMessageBuilder.new(
-
message: self,
-
in_reply_to: in_reply_to,
-
in_reply_to_external_id: in_reply_to_external_id
-
).perform
-
end
-
-
1
def ensure_content_type
-
6
self.content_type ||= Message.content_types[:text]
-
end
-
-
1
def execute_after_create_commit_callbacks
-
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
-
6
reopen_conversation
-
6
notify_via_mail
-
6
set_conversation_activity
-
6
dispatch_create_events
-
6
send_reply
-
6
execute_message_template_hooks
-
6
update_contact_activity
-
end
-
-
1
def update_contact_activity
-
6
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
-
end
-
-
1
def update_waiting_since
-
3
if human_response? && !private && conversation.waiting_since.present?
-
Rails.configuration.dispatcher.dispatch(
-
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
-
)
-
conversation.update(waiting_since: nil)
-
end
-
3
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
-
end
-
-
1
def human_response?
-
# if the sender is not a user, it's not a human response
-
# if automation rule id is present, it's not a human response
-
# if campaign id is present, it's not a human response
-
6
outgoing? &&
-
content_attributes['automation_rule_id'].blank? &&
-
additional_attributes['campaign_id'].blank? &&
-
sender.is_a?(User)
-
end
-
-
1
def dispatch_create_events
-
6
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
-
-
6
if valid_first_reply?
-
3
Rails.configuration.dispatcher.dispatch(FIRST_REPLY_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
-
3
conversation.update(first_reply_created_at: created_at, waiting_since: nil)
-
else
-
3
update_waiting_since
-
end
-
end
-
-
1
def dispatch_update_event
-
# ref: https://github.com/rails/rails/issues/44500
-
# we want to skip the update event if the message is not updated
-
return if previous_changes.blank?
-
-
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
-
previous_changes: previous_changes)
-
end
-
-
1
def send_reply
-
# FIXME: Giving it few seconds for the attachment to be uploaded to the service
-
# active storage attaches the file only after commit
-
6
attachments.blank? ? ::SendReplyJob.perform_later(id) : ::SendReplyJob.set(wait: 2.seconds).perform_later(id)
-
end
-
-
1
def reopen_conversation
-
6
return if conversation.muted?
-
6
return unless incoming?
-
-
3
conversation.open! if conversation.snoozed?
-
-
3
reopen_resolved_conversation if conversation.resolved?
-
end
-
-
1
def reopen_resolved_conversation
-
# mark resolved bot conversation as pending to be reopened by bot processor service
-
if conversation.inbox.active_bot?
-
conversation.pending!
-
elsif conversation.inbox.api?
-
Current.executed_by = sender if reopened_by_contact?
-
conversation.open!
-
else
-
conversation.open!
-
end
-
end
-
-
1
def reopened_by_contact?
-
incoming? && !private? && Current.user.class != sender.class && sender.instance_of?(Contact)
-
end
-
-
1
def execute_message_template_hooks
-
6
::MessageTemplates::HookExecutionService.new(message: self).perform
-
end
-
-
1
def email_notifiable_webwidget?
-
3
inbox.web_widget? && inbox.channel.continuity_via_email
-
end
-
-
1
def email_notifiable_api_channel?
-
inbox.api? && inbox.account.feature_enabled?('email_continuity_on_api_channel')
-
end
-
-
1
def email_notifiable_channel?
-
3
email_notifiable_webwidget? || %w[Email].include?(inbox.inbox_type) || email_notifiable_api_channel?
-
end
-
-
1
def can_notify_via_mail?
-
6
return unless email_notifiable_message?
-
3
return unless email_notifiable_channel?
-
3
return if conversation.contact.email.blank?
-
-
3
true
-
end
-
-
1
def notify_via_mail
-
6
return unless can_notify_via_mail?
-
-
3
trigger_notify_via_mail
-
end
-
-
1
def trigger_notify_via_mail
-
3
return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email'
-
-
# will set a redis key for the conversation so that we don't need to send email for every new message
-
# last few messages coupled together is sent every 2 minutes rather than one email for each message
-
# if redis key exists there is an unprocessed job that will take care of delivering the email
-
3
return if Redis::Alfred.get(conversation_mail_key).present?
-
-
3
Redis::Alfred.setex(conversation_mail_key, id)
-
3
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id)
-
end
-
-
1
def conversation_mail_key
-
6
format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
-
end
-
-
1
def validate_attachments_limit(_attachment)
-
errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS
-
end
-
-
1
def set_conversation_activity
-
# rubocop:disable Rails/SkipsModelValidations
-
6
conversation.update_columns(last_activity_at: created_at)
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
end
-
-
1
Message.prepend_mod_with('Message')
-
# == Schema Information
-
#
-
# Table name: notes
-
#
-
# id :bigint not null, primary key
-
# content :text not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# contact_id :bigint not null
-
# user_id :bigint
-
#
-
# Indexes
-
#
-
# index_notes_on_account_id (account_id)
-
# index_notes_on_contact_id (contact_id)
-
# index_notes_on_user_id (user_id)
-
#
-
class Note < ApplicationRecord
-
before_validation :ensure_account_id
-
validates :content, presence: true
-
validates :account_id, presence: true
-
validates :contact_id, presence: true
-
-
belongs_to :account
-
belongs_to :contact
-
belongs_to :user, optional: true
-
-
scope :latest, -> { order(created_at: :desc) }
-
-
private
-
-
def ensure_account_id
-
self.account_id = contact&.account_id
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: notifications
-
#
-
# id :bigint not null, primary key
-
# last_activity_at :datetime
-
# meta :jsonb
-
# notification_type :integer not null
-
# primary_actor_type :string not null
-
# read_at :datetime
-
# secondary_actor_type :string
-
# snoozed_until :datetime
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
# primary_actor_id :bigint not null
-
# secondary_actor_id :bigint
-
# user_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_notifications_on_account_id (account_id)
-
# index_notifications_on_last_activity_at (last_activity_at)
-
# index_notifications_on_user_id (user_id)
-
# uniq_primary_actor_per_account_notifications (primary_actor_type,primary_actor_id)
-
# uniq_secondary_actor_per_account_notifications (secondary_actor_type,secondary_actor_id)
-
#
-
-
1
class Notification < ApplicationRecord
-
1
include MessageFormatHelper
-
1
belongs_to :account
-
1
belongs_to :user
-
-
1
belongs_to :primary_actor, polymorphic: true
-
1
belongs_to :secondary_actor, polymorphic: true, optional: true
-
-
1
NOTIFICATION_TYPES = {
-
conversation_creation: 1,
-
conversation_assignment: 2,
-
assigned_conversation_new_message: 3,
-
conversation_mention: 4,
-
participating_conversation_new_message: 5,
-
sla_missed_first_response: 6,
-
sla_missed_next_response: 7,
-
sla_missed_resolution: 8
-
}.freeze
-
-
1
enum notification_type: NOTIFICATION_TYPES
-
-
1
before_create :set_last_activity_at
-
1
after_create_commit :process_notification_delivery, :dispatch_create_event
-
1
after_destroy_commit :dispatch_destroy_event
-
1
after_update_commit :dispatch_update_event
-
-
1
PRIMARY_ACTORS = ['Conversation'].freeze
-
-
1
def push_event_data
-
# Secondary actor could be nil for cases like system assigning conversation
-
payload = {
-
id: id,
-
notification_type: notification_type,
-
primary_actor_type: primary_actor_type,
-
primary_actor_id: primary_actor_id,
-
read_at: read_at,
-
secondary_actor: secondary_actor&.push_event_data,
-
user: user&.push_event_data,
-
created_at: created_at.to_i,
-
last_activity_at: last_activity_at.to_i,
-
snoozed_until: snoozed_until,
-
meta: meta,
-
account_id: account_id
-
}
-
payload.merge!(primary_actor_data) if primary_actor.present?
-
payload
-
end
-
-
1
def fcm_push_data
-
{
-
id: id,
-
notification_type: notification_type,
-
primary_actor_id: primary_actor_id,
-
primary_actor_type: primary_actor_type,
-
primary_actor: primary_actor.push_event_data.with_indifferent_access.slice('conversation_id', 'id')
-
}
-
end
-
-
# rubocop:disable Metrics/MethodLength
-
1
def push_message_title
-
notification_title_map = {
-
'conversation_creation' => 'notifications.notification_title.conversation_creation',
-
'conversation_assignment' => 'notifications.notification_title.conversation_assignment',
-
'assigned_conversation_new_message' => 'notifications.notification_title.assigned_conversation_new_message',
-
'participating_conversation_new_message' => 'notifications.notification_title.assigned_conversation_new_message',
-
'conversation_mention' => 'notifications.notification_title.conversation_mention',
-
'sla_missed_first_response' => 'notifications.notification_title.sla_missed_first_response',
-
'sla_missed_next_response' => 'notifications.notification_title.sla_missed_next_response',
-
'sla_missed_resolution' => 'notifications.notification_title.sla_missed_resolution'
-
}
-
-
i18n_key = notification_title_map[notification_type]
-
return '' unless i18n_key
-
-
if notification_type == 'conversation_creation'
-
I18n.t(i18n_key, display_id: conversation.display_id, inbox_name: primary_actor.inbox.name)
-
elsif %w[conversation_assignment assigned_conversation_new_message participating_conversation_new_message
-
conversation_mention].include?(notification_type)
-
I18n.t(i18n_key, display_id: conversation.display_id)
-
else
-
I18n.t(i18n_key, display_id: primary_actor.display_id)
-
end
-
end
-
# rubocop:enable Metrics/MethodLength
-
-
1
def push_message_body
-
case notification_type
-
when 'conversation_creation', 'sla_missed_first_response'
-
message_body(conversation.messages.first)
-
when 'assigned_conversation_new_message', 'participating_conversation_new_message', 'conversation_mention'
-
message_body(secondary_actor)
-
when 'conversation_assignment', 'sla_missed_next_response', 'sla_missed_resolution'
-
message_body((conversation.messages.incoming.last || conversation.messages.outgoing.last))
-
else
-
''
-
end
-
end
-
-
1
def conversation
-
primary_actor
-
end
-
-
1
private
-
-
1
def message_body(actor)
-
sender_name = sender_name(actor)
-
content = message_content(actor)
-
"#{sender_name}: #{content}"
-
end
-
-
1
def sender_name(actor)
-
actor.try(:sender)&.name || ''
-
end
-
-
1
def message_content(actor)
-
content = actor.try(:content)
-
attachments = actor.try(:attachments)
-
-
if content.present?
-
transform_user_mention_content(content.truncate_words(10))
-
else
-
attachments.present? ? I18n.t('notifications.attachment') : I18n.t('notifications.no_content')
-
end
-
end
-
-
1
def process_notification_delivery
-
Notification::PushNotificationJob.perform_later(self) if user_subscribed_to_notification?('push')
-
-
# Should we do something about the case where user subscribed to both push and email ?
-
# In future, we could probably add condition here to enqueue the job for 30 seconds later
-
# when push enabled and then check in email job whether notification has been read already.
-
Notification::EmailNotificationJob.perform_later(self) if user_subscribed_to_notification?('email')
-
-
Notification::RemoveDuplicateNotificationJob.perform_later(self)
-
end
-
-
1
def user_subscribed_to_notification?(delivery_type)
-
notification_setting = user.notification_settings.find_by(account_id: account.id)
-
return false if notification_setting.blank?
-
-
# Check if the user has subscribed to the specified type of notification
-
notification_setting.public_send("#{delivery_type}_#{notification_type}?")
-
end
-
-
1
def dispatch_create_event
-
Rails.configuration.dispatcher.dispatch(NOTIFICATION_CREATED, Time.zone.now, notification: self)
-
end
-
-
1
def dispatch_update_event
-
Rails.configuration.dispatcher.dispatch(NOTIFICATION_UPDATED, Time.zone.now, notification: self)
-
end
-
-
1
def dispatch_destroy_event
-
Rails.configuration.dispatcher.dispatch(NOTIFICATION_DELETED, Time.zone.now, notification: self)
-
end
-
-
1
def set_last_activity_at
-
self.last_activity_at = created_at
-
end
-
-
1
def primary_actor_data
-
{
-
primary_actor: primary_actor&.push_event_data,
-
# TODO: Rename push_message_title to push_message_body
-
push_message_title: push_message_body,
-
push_message_body: push_message_body
-
}
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: notification_settings
-
#
-
# id :bigint not null, primary key
-
# email_flags :integer default(0), not null
-
# push_flags :integer default(0), not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
# user_id :integer
-
#
-
# Indexes
-
#
-
# by_account_user (account_id,user_id) UNIQUE
-
#
-
-
1
class NotificationSetting < ApplicationRecord
-
# used for single column multi flags
-
1
include FlagShihTzu
-
-
1
belongs_to :account
-
1
belongs_to :user
-
-
1
DEFAULT_QUERY_SETTING = {
-
flag_query_mode: :bit_operator,
-
check_for_column: false
-
}.freeze
-
-
9
EMAIL_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "email_#{key}".to_sym }.invert.freeze
-
9
PUSH_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "push_#{key}".to_sym }.invert.freeze
-
-
1
has_flags EMAIL_NOTIFICATION_FLAGS.merge(column: 'email_flags').merge(DEFAULT_QUERY_SETTING)
-
1
has_flags PUSH_NOTIFICATION_FLAGS.merge(column: 'push_flags').merge(DEFAULT_QUERY_SETTING)
-
end
-
# == Schema Information
-
#
-
# Table name: notification_subscriptions
-
#
-
# id :bigint not null, primary key
-
# identifier :text
-
# subscription_attributes :jsonb not null
-
# subscription_type :integer not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# user_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_notification_subscriptions_on_identifier (identifier) UNIQUE
-
# index_notification_subscriptions_on_user_id (user_id)
-
#
-
-
class NotificationSubscription < ApplicationRecord
-
belongs_to :user
-
validates :identifier, presence: true
-
-
SUBSCRIPTION_TYPES = {
-
browser_push: 1,
-
fcm: 2
-
}.freeze
-
-
enum subscription_type: SUBSCRIPTION_TYPES
-
end
-
# == Schema Information
-
#
-
# Table name: platform_apps
-
#
-
# id :bigint not null, primary key
-
# name :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
class PlatformApp < ApplicationRecord
-
include AccessTokenable
-
-
validates :name, presence: true
-
-
has_many :platform_app_permissibles, dependent: :destroy_async
-
end
-
# == Schema Information
-
#
-
# Table name: platform_app_permissibles
-
#
-
# id :bigint not null, primary key
-
# permissible_type :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# permissible_id :bigint not null
-
# platform_app_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_platform_app_permissibles_on_permissibles (permissible_type,permissible_id)
-
# index_platform_app_permissibles_on_platform_app_id (platform_app_id)
-
# unique_permissibles_index (platform_app_id,permissible_id,permissible_type) UNIQUE
-
#
-
class PlatformAppPermissible < ApplicationRecord
-
validates :platform_app, presence: true
-
validates :platform_app_id, uniqueness: { scope: [:permissible_id, :permissible_type] }
-
-
belongs_to :platform_app
-
belongs_to :permissible, polymorphic: true
-
end
-
# == Schema Information
-
#
-
# Table name: portals
-
#
-
# id :bigint not null, primary key
-
# archived :boolean default(FALSE)
-
# color :string
-
# config :jsonb
-
# custom_domain :string
-
# header_text :text
-
# homepage_link :string
-
# name :string not null
-
# page_title :string
-
# slug :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer not null
-
# channel_web_widget_id :bigint
-
#
-
# Indexes
-
#
-
# index_portals_on_channel_web_widget_id (channel_web_widget_id)
-
# index_portals_on_custom_domain (custom_domain) UNIQUE
-
# index_portals_on_slug (slug) UNIQUE
-
#
-
class Portal < ApplicationRecord
-
include Rails.application.routes.url_helpers
-
-
belongs_to :account
-
has_many :categories, dependent: :destroy_async
-
has_many :folders, through: :categories
-
has_many :articles, dependent: :destroy_async
-
has_one_attached :logo
-
has_many :inboxes, dependent: :nullify
-
belongs_to :channel_web_widget, class_name: 'Channel::WebWidget', optional: true
-
-
before_validation -> { normalize_empty_string_to_nil(%i[custom_domain homepage_link]) }
-
validates :account_id, presence: true
-
validates :name, presence: true
-
validates :slug, presence: true, uniqueness: true
-
validates :custom_domain, uniqueness: true, allow_nil: true
-
validate :config_json_format
-
-
scope :active, -> { where(archived: false) }
-
-
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
-
-
def file_base_data
-
{
-
id: logo.id,
-
portal_id: id,
-
file_type: logo.content_type,
-
account_id: account_id,
-
file_url: url_for(logo),
-
blob_id: logo.blob_id,
-
filename: logo.filename.to_s
-
}
-
end
-
-
def default_locale
-
config['default_locale'] || 'en'
-
end
-
-
private
-
-
def config_json_format
-
config['default_locale'] = default_locale
-
denied_keys = config.keys - CONFIG_JSON_KEYS
-
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: related_categories
-
#
-
# id :bigint not null, primary key
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# category_id :bigint
-
# related_category_id :bigint
-
#
-
# Indexes
-
#
-
# index_related_categories_on_category_id_and_related_category_id (category_id,related_category_id) UNIQUE
-
# index_related_categories_on_related_category_id_and_category_id (related_category_id,category_id) UNIQUE
-
#
-
class RelatedCategory < ApplicationRecord
-
belongs_to :related_category, class_name: 'Category'
-
belongs_to :category, class_name: 'Category'
-
end
-
# == Schema Information
-
#
-
# Table name: reporting_events
-
#
-
# id :bigint not null, primary key
-
# event_end_time :datetime
-
# event_start_time :datetime
-
# name :string
-
# value :float
-
# value_in_business_hours :float
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
# conversation_id :integer
-
# inbox_id :integer
-
# user_id :integer
-
#
-
# Indexes
-
#
-
# index_reporting_events_on_account_id (account_id)
-
# index_reporting_events_on_conversation_id (conversation_id)
-
# index_reporting_events_on_created_at (created_at)
-
# index_reporting_events_on_inbox_id (inbox_id)
-
# index_reporting_events_on_name (name)
-
# index_reporting_events_on_user_id (user_id)
-
# reporting_events__account_id__name__created_at (account_id,name,created_at)
-
#
-
-
class ReportingEvent < ApplicationRecord
-
validates :account_id, presence: true
-
validates :name, presence: true
-
validates :value, presence: true
-
-
belongs_to :account
-
belongs_to :user, optional: true
-
belongs_to :inbox, optional: true
-
belongs_to :conversation, optional: true
-
end
-
# == Schema Information
-
#
-
# Table name: users
-
#
-
# id :integer not null, primary key
-
# availability :integer default("online")
-
# confirmation_sent_at :datetime
-
# confirmation_token :string
-
# confirmed_at :datetime
-
# current_sign_in_at :datetime
-
# current_sign_in_ip :string
-
# custom_attributes :jsonb
-
# display_name :string
-
# email :string
-
# encrypted_password :string default(""), not null
-
# last_sign_in_at :datetime
-
# last_sign_in_ip :string
-
# message_signature :text
-
# name :string not null
-
# provider :string default("email"), not null
-
# pubsub_token :string
-
# remember_created_at :datetime
-
# reset_password_sent_at :datetime
-
# reset_password_token :string
-
# sign_in_count :integer default(0), not null
-
# tokens :json
-
# type :string
-
# ui_settings :jsonb
-
# uid :string default(""), not null
-
# unconfirmed_email :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
# Indexes
-
#
-
# index_users_on_email (email)
-
# index_users_on_pubsub_token (pubsub_token) UNIQUE
-
# index_users_on_reset_password_token (reset_password_token) UNIQUE
-
# index_users_on_uid_and_provider (uid,provider) UNIQUE
-
#
-
1
class SuperAdmin < User
-
end
-
# == Schema Information
-
#
-
# Table name: teams
-
#
-
# id :bigint not null, primary key
-
# allow_auto_assign :boolean default(TRUE)
-
# description :text
-
# name :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_teams_on_account_id (account_id)
-
# index_teams_on_name_and_account_id (name,account_id) UNIQUE
-
#
-
1
class Team < ApplicationRecord
-
1
include AccountCacheRevalidator
-
-
1
belongs_to :account
-
1
has_many :team_members, dependent: :destroy_async
-
1
has_many :members, through: :team_members, source: :user
-
1
has_many :conversations, dependent: :nullify
-
-
1
validates :name,
-
presence: { message: I18n.t('errors.validations.presence') },
-
uniqueness: { scope: :account_id }
-
-
1
before_validation do
-
self.name = name.downcase if attribute_present?('name')
-
end
-
-
# Adds multiple members to the team
-
# @param user_ids [Array<Integer>] Array of user IDs to add as members
-
# @return [Array<User>] Array of newly added members
-
1
def add_members(user_ids)
-
team_members_to_create = user_ids.map { |user_id| { user_id: user_id } }
-
created_members = team_members.create(team_members_to_create)
-
added_users = created_members.filter_map(&:user)
-
-
update_account_cache
-
added_users
-
end
-
-
# Removes multiple members from the team
-
# @param user_ids [Array<Integer>] Array of user IDs to remove
-
# @return [void]
-
1
def remove_members(user_ids)
-
team_members.where(user_id: user_ids).destroy_all
-
update_account_cache
-
end
-
-
1
def messages
-
account.messages.where(conversation_id: conversations.pluck(:id))
-
end
-
-
1
def reporting_events
-
account.reporting_events.where(conversation_id: conversations.pluck(:id))
-
end
-
-
1
def push_event_data
-
{
-
id: id,
-
name: name
-
}
-
end
-
end
-
-
1
Team.include_mod_with('Audit::Team')
-
# == Schema Information
-
#
-
# Table name: team_members
-
#
-
# id :bigint not null, primary key
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# team_id :bigint not null
-
# user_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_team_members_on_team_id (team_id)
-
# index_team_members_on_team_id_and_user_id (team_id,user_id) UNIQUE
-
# index_team_members_on_user_id (user_id)
-
#
-
class TeamMember < ApplicationRecord
-
belongs_to :user
-
belongs_to :team
-
validates :user_id, uniqueness: { scope: :team_id }
-
end
-
-
TeamMember.include_mod_with('Audit::TeamMember')
-
# == Schema Information
-
#
-
# Table name: telegram_bots
-
#
-
# id :integer not null, primary key
-
# auth_key :string
-
# name :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
#
-
-
class TelegramBot < ApplicationRecord
-
belongs_to :account
-
has_one :inbox, as: :channel, dependent: :destroy_async
-
validates :auth_key, uniqueness: { scope: :account_id }
-
end
-
# == Schema Information
-
#
-
# Table name: users
-
#
-
# id :integer not null, primary key
-
# availability :integer default("online")
-
# confirmation_sent_at :datetime
-
# confirmation_token :string
-
# confirmed_at :datetime
-
# current_sign_in_at :datetime
-
# current_sign_in_ip :string
-
# custom_attributes :jsonb
-
# display_name :string
-
# email :string
-
# encrypted_password :string default(""), not null
-
# last_sign_in_at :datetime
-
# last_sign_in_ip :string
-
# message_signature :text
-
# name :string not null
-
# provider :string default("email"), not null
-
# pubsub_token :string
-
# remember_created_at :datetime
-
# reset_password_sent_at :datetime
-
# reset_password_token :string
-
# sign_in_count :integer default(0), not null
-
# tokens :json
-
# type :string
-
# ui_settings :jsonb
-
# uid :string default(""), not null
-
# unconfirmed_email :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
# Indexes
-
#
-
# index_users_on_email (email)
-
# index_users_on_pubsub_token (pubsub_token) UNIQUE
-
# index_users_on_reset_password_token (reset_password_token) UNIQUE
-
# index_users_on_uid_and_provider (uid,provider) UNIQUE
-
#
-
-
1
class User < ApplicationRecord
-
1
include AccessTokenable
-
1
include Avatarable
-
# Include default devise modules.
-
1
include DeviseTokenAuth::Concerns::User
-
1
include Pubsubable
-
1
include Rails.application.routes.url_helpers
-
1
include Reportable
-
1
include SsoAuthenticatable
-
1
include UserAttributeHelpers
-
-
1
devise :database_authenticatable,
-
:registerable,
-
:recoverable,
-
:rememberable,
-
:trackable,
-
:validatable,
-
:confirmable,
-
:password_has_required_content,
-
:omniauthable, omniauth_providers: [:google_oauth2]
-
-
# TODO: remove in a future version once online status is moved to account users
-
# remove the column availability from users
-
1
enum availability: { online: 0, offline: 1, busy: 2 }
-
-
# The validation below has been commented out as it does not
-
# work because :validatable in devise overrides this.
-
# validates_uniqueness_of :email, scope: :account_id
-
-
1
validates :email, presence: true
-
-
1
has_many :account_users, dependent: :destroy_async
-
1
has_many :accounts, through: :account_users
-
1
accepts_nested_attributes_for :account_users
-
-
1
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
-
1
alias_attribute :conversations, :assigned_conversations
-
1
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
-
1
has_many :conversation_participants, dependent: :destroy_async
-
1
has_many :participating_conversations, through: :conversation_participants, source: :conversation
-
-
1
has_many :inbox_members, dependent: :destroy_async
-
1
has_many :inboxes, through: :inbox_members, source: :inbox
-
1
has_many :messages, as: :sender, dependent: :nullify
-
1
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
-
-
1
has_many :custom_filters, dependent: :destroy_async
-
1
has_many :dashboard_apps, dependent: :nullify
-
1
has_many :mentions, dependent: :destroy_async
-
1
has_many :notes, dependent: :nullify
-
1
has_many :notification_settings, dependent: :destroy_async
-
1
has_many :notification_subscriptions, dependent: :destroy_async
-
1
has_many :notifications, dependent: :destroy_async
-
1
has_many :team_members, dependent: :destroy_async
-
1
has_many :teams, through: :team_members
-
1
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
-
# rubocop:disable Rails/HasManyOrHasOneDependent
-
# we are handling this in `remove_macros` callback
-
1
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
-
# rubocop:enable Rails/HasManyOrHasOneDependent
-
-
1
before_validation :set_password_and_uid, on: :create
-
1
after_destroy :remove_macros
-
-
1
scope :order_by_full_name, -> { order('lower(name) ASC') }
-
-
1
before_validation do
-
6
self.email = email.try(:downcase)
-
end
-
-
1
def send_devise_notification(notification, *args)
-
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later
-
end
-
-
1
def set_password_and_uid
-
3
self.uid = email
-
end
-
-
1
def assigned_inboxes
-
administrator? ? Current.account.inboxes : inboxes.where(account_id: Current.account.id)
-
end
-
-
1
def serializable_hash(options = nil)
-
super(options).merge(confirmed: confirmed?)
-
end
-
-
1
def push_event_data
-
{
-
9
id: id,
-
name: name,
-
available_name: available_name,
-
avatar_url: avatar_url,
-
type: 'user',
-
availability_status: availability_status,
-
thumbnail: avatar_url
-
}
-
end
-
-
1
def webhook_data
-
{
-
id: id,
-
name: name,
-
email: email,
-
type: 'user'
-
}
-
end
-
-
# https://github.com/lynndylanhurley/devise_token_auth/blob/6d7780ee0b9750687e7e2871b9a1c6368f2085a9/app/models/devise_token_auth/concerns/user.rb#L45
-
# Since this method is overriden in devise_token_auth it breaks the email reconfirmation flow.
-
1
def will_save_change_to_email?
-
33
mutations_from_database.changed?('email')
-
end
-
-
1
def self.from_email(email)
-
find_by(email: email&.downcase)
-
end
-
-
1
private
-
-
1
def remove_macros
-
macros.personal.destroy_all
-
end
-
end
-
-
1
User.include_mod_with('Audit::User')
-
1
User.include_mod_with('Concerns::User')
-
# == Schema Information
-
#
-
# Table name: webhooks
-
#
-
# id :bigint not null, primary key
-
# subscriptions :jsonb
-
# url :string
-
# webhook_type :integer default("account_type")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :integer
-
# inbox_id :integer
-
#
-
# Indexes
-
#
-
# index_webhooks_on_account_id_and_url (account_id,url) UNIQUE
-
#
-
-
class Webhook < ApplicationRecord
-
belongs_to :account
-
belongs_to :inbox, optional: true
-
-
validates :account_id, presence: true
-
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
-
validate :validate_webhook_subscriptions
-
enum webhook_type: { account_type: 0, inbox_type: 1 }
-
-
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
-
message_created message_updated webwidget_triggered inbox_created inbox_updated].freeze
-
-
private
-
-
def validate_webhook_subscriptions
-
invalid_subscriptions = !subscriptions.instance_of?(Array) ||
-
subscriptions.blank? ||
-
(subscriptions.uniq - ALLOWED_WEBHOOK_EVENTS).length.positive?
-
errors.add(:subscriptions, I18n.t('errors.webhook.invalid')) if invalid_subscriptions
-
end
-
end
-
-
Webhook.include_mod_with('Audit::Webhook')
-
# == Schema Information
-
#
-
# Table name: working_hours
-
#
-
# id :bigint not null, primary key
-
# close_hour :integer
-
# close_minutes :integer
-
# closed_all_day :boolean default(FALSE)
-
# day_of_week :integer not null
-
# open_all_day :boolean default(FALSE)
-
# open_hour :integer
-
# open_minutes :integer
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint
-
# inbox_id :bigint
-
#
-
# Indexes
-
#
-
# index_working_hours_on_account_id (account_id)
-
# index_working_hours_on_inbox_id (inbox_id)
-
#
-
1
class WorkingHour < ApplicationRecord
-
1
belongs_to :inbox
-
-
1
before_validation :ensure_open_all_day_hours
-
1
before_save :assign_account
-
-
1
validates :open_hour, presence: true, unless: :closed_all_day?
-
1
validates :open_minutes, presence: true, unless: :closed_all_day?
-
1
validates :close_hour, presence: true, unless: :closed_all_day?
-
1
validates :close_minutes, presence: true, unless: :closed_all_day?
-
-
1
validates :open_hour, inclusion: 0..23, unless: :closed_all_day?
-
1
validates :close_hour, inclusion: 0..23, unless: :closed_all_day?
-
1
validates :open_minutes, inclusion: 0..59, unless: :closed_all_day?
-
1
validates :close_minutes, inclusion: 0..59, unless: :closed_all_day?
-
-
1
validate :close_after_open, unless: :closed_all_day?
-
1
validate :open_all_day_and_closed_all_day
-
-
1
def self.today
-
# While getting the day of the week, consider the timezone as well. `first` would
-
# return the first working hour from the list of working hours available per week.
-
inbox = first.inbox
-
find_by(day_of_week: Time.zone.now.in_time_zone(inbox.timezone).to_date.wday)
-
end
-
-
1
def open_at?(time)
-
return false if closed_all_day?
-
-
open_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: open_hour, min: open_minutes })
-
close_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: close_hour, min: close_minutes })
-
-
time.between?(open_time, close_time)
-
end
-
-
1
def open_now?
-
inbox_time = Time.zone.now.in_time_zone(inbox.timezone)
-
open_at?(inbox_time)
-
end
-
-
1
def closed_now?
-
!open_now?
-
end
-
-
1
private
-
-
1
def assign_account
-
63
self.account_id = inbox.account_id
-
end
-
-
1
def close_after_open
-
45
return unless open_hour.hours + open_minutes.minutes >= close_hour.hours + close_minutes.minutes
-
-
errors.add(:close_hour, 'Closing time cannot be before opening time')
-
end
-
-
1
def ensure_open_all_day_hours
-
63
return unless open_all_day?
-
-
self.open_hour = 0
-
self.open_minutes = 0
-
self.close_hour = 23
-
self.close_minutes = 59
-
end
-
-
1
def open_all_day_and_closed_all_day
-
63
return unless open_all_day? && closed_all_day?
-
-
errors.add(:base, 'open_all_day and closed_all_day cannot be true at the same time')
-
end
-
end
-
class AccountPolicy < ApplicationPolicy
-
def show?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def cache_keys?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def limits?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def update_active_at?
-
true
-
end
-
-
def subscription?
-
@account_user.administrator?
-
end
-
-
def checkout?
-
@account_user.administrator?
-
end
-
-
def toggle_deletion?
-
@account_user.administrator?
-
end
-
end
-
class AgentBotPolicy < ApplicationPolicy
-
def index?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def avatar?
-
@account_user.administrator?
-
end
-
end
-
class ApplicationPolicy
-
attr_reader :user_context, :user, :record, :account, :account_user
-
-
def initialize(user_context, record)
-
@user_context = user_context
-
@user = user_context[:user]
-
@account = user_context[:account]
-
@account_user = user_context[:account_user]
-
@record = record
-
end
-
-
def index?
-
false
-
end
-
-
def show?
-
scope.exists?(id: record.id)
-
end
-
-
def create?
-
false
-
end
-
-
def new?
-
create?
-
end
-
-
def update?
-
false
-
end
-
-
def edit?
-
update?
-
end
-
-
def destroy?
-
false
-
end
-
-
def scope
-
Pundit.policy_scope!(user_context, record.class)
-
end
-
-
class Scope
-
attr_reader :user_context, :user, :scope, :account, :account_user
-
-
def initialize(user_context, scope)
-
@user_context = user_context
-
@user = user_context[:user]
-
@account = user_context[:account]
-
@account_user = user_context[:account_user]
-
@scope = scope
-
end
-
-
def resolve
-
scope
-
end
-
end
-
end
-
class ArticlePolicy < ApplicationPolicy
-
def index?
-
@account.users.include?(@user)
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account_user.administrator?
-
end
-
-
def edit?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def reorder?
-
@account_user.administrator?
-
end
-
end
-
-
ArticlePolicy.prepend_mod_with('ArticlePolicy')
-
class AutomationRulePolicy < ApplicationPolicy
-
def index?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def clone?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
class CampaignPolicy < ApplicationPolicy
-
def index?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
class CategoryPolicy < ApplicationPolicy
-
def index?
-
@account.users.include?(@user)
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account_user.administrator?
-
end
-
-
def edit?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
-
CategoryPolicy.prepend_mod_with('CategoryPolicy')
-
class ContactPolicy < ApplicationPolicy
-
def index?
-
true
-
end
-
-
def active?
-
true
-
end
-
-
def import?
-
@account_user.administrator?
-
end
-
-
def export?
-
@account_user.administrator?
-
end
-
-
def search?
-
true
-
end
-
-
def filter?
-
true
-
end
-
-
def update?
-
true
-
end
-
-
def contactable_inboxes?
-
true
-
end
-
-
def destroy_custom_attributes?
-
true
-
end
-
-
def show?
-
true
-
end
-
-
def create?
-
true
-
end
-
-
def avatar?
-
true
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
class ConversationPolicy < ApplicationPolicy
-
def index?
-
true
-
end
-
end
-
class CsatSurveyResponsePolicy < ApplicationPolicy
-
def index?
-
@account_user.administrator?
-
end
-
-
def metrics?
-
@account_user.administrator?
-
end
-
-
def download?
-
@account_user.administrator?
-
end
-
end
-
class CustomFilterPolicy < ApplicationPolicy
-
def create?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def show?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def index?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def update?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def destroy?
-
@account_user.administrator? || @account_user.agent?
-
end
-
end
-
class HookPolicy < ApplicationPolicy
-
def create?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def process_event?
-
true
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
class InboxPolicy < ApplicationPolicy
-
class Scope
-
attr_reader :user_context, :user, :scope, :account, :account_user
-
-
def initialize(user_context, scope)
-
@user_context = user_context
-
@user = user_context[:user]
-
@account = user_context[:account]
-
@account_user = user_context[:account_user]
-
@scope = scope
-
end
-
-
def resolve
-
user.assigned_inboxes
-
end
-
end
-
-
def index?
-
true
-
end
-
-
def show?
-
# FIXME: for agent bots, lets bring this validation to policies as well in future
-
return true if @user.is_a?(AgentBot)
-
-
Current.user.assigned_inboxes.include? record
-
end
-
-
def assignable_agents?
-
true
-
end
-
-
def agent_bot?
-
true
-
end
-
-
def campaigns?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def set_agent_bot?
-
@account_user.administrator?
-
end
-
-
def avatar?
-
@account_user.administrator?
-
end
-
end
-
class LabelPolicy < ApplicationPolicy
-
def index?
-
@account_user.administrator? || @account_user.agent?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
class MacroPolicy < ApplicationPolicy
-
def index?
-
true
-
end
-
-
def create?
-
true
-
end
-
-
def show?
-
@record.global? || author?
-
end
-
-
def update?
-
author? || (@account_user.administrator? && @record.global?)
-
end
-
-
def destroy?
-
author? || orphan_record?
-
end
-
-
def execute?
-
@record.global? || author?
-
end
-
-
private
-
-
def author?
-
@record.created_by == @account_user.user
-
end
-
-
def orphan_record?
-
return @account_user.administrator? if @record.created_by.nil? && @record.global?
-
-
false
-
end
-
end
-
class PortalPolicy < ApplicationPolicy
-
def index?
-
@account.users.include?(@user)
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
@account.users.include?(@user)
-
end
-
-
def edit?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def logo?
-
@account_user.administrator?
-
end
-
end
-
-
PortalPolicy.prepend_mod_with('PortalPolicy')
-
class ReportPolicy < ApplicationPolicy
-
def view?
-
@account_user.administrator?
-
end
-
end
-
-
ReportPolicy.prepend_mod_with('ReportPolicy')
-
class TeamMemberPolicy < ApplicationPolicy
-
def index?
-
true
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
end
-
class TeamPolicy < ApplicationPolicy
-
def index?
-
true
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def show?
-
true
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
end
-
class UserPolicy < ApplicationPolicy
-
def index?
-
true
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def bulk_create?
-
@account_user.administrator?
-
end
-
end
-
class WebhookPolicy < ApplicationPolicy
-
def index?
-
@account_user.administrator?
-
end
-
-
def update?
-
@account_user.administrator?
-
end
-
-
def destroy?
-
@account_user.administrator?
-
end
-
-
def create?
-
@account_user.administrator?
-
end
-
end
-
class AgentBotPresenter < SimpleDelegator
-
def access_token
-
return if account_id.blank?
-
-
Current.account.id == account_id ? super&.token : nil
-
end
-
end
-
1
class Conversations::EventDataPresenter < SimpleDelegator
-
1
def push_data
-
{
-
12
additional_attributes: additional_attributes,
-
can_reply: can_reply?,
-
channel: inbox.try(:channel_type),
-
contact_inbox: contact_inbox,
-
id: display_id,
-
inbox_id: inbox_id,
-
messages: push_messages,
-
labels: label_list,
-
meta: push_meta,
-
status: status,
-
custom_attributes: custom_attributes,
-
snoozed_until: snoozed_until,
-
unread_count: unread_incoming_messages.count,
-
first_reply_created_at: first_reply_created_at,
-
priority: priority,
-
waiting_since: waiting_since.to_i,
-
**push_timestamps
-
}
-
end
-
-
1
private
-
-
1
def push_messages
-
12
[messages.chat.last&.push_event_data].compact
-
end
-
-
1
def push_meta
-
{
-
12
sender: contact.push_event_data,
-
assignee: assignee&.push_event_data,
-
team: team&.push_event_data,
-
hmac_verified: contact_inbox&.hmac_verified
-
}
-
end
-
-
1
def push_timestamps
-
{
-
12
agent_last_seen_at: agent_last_seen_at.to_i,
-
contact_last_seen_at: contact_last_seen_at.to_i,
-
last_activity_at: last_activity_at.to_i,
-
timestamp: last_activity_at.to_i,
-
created_at: created_at.to_i,
-
updated_at: updated_at.to_f
-
}
-
end
-
end
-
1
Conversations::EventDataPresenter.prepend_mod_with('Conversations::EventDataPresenter')
-
class HtmlParser
-
def self.parse_reply(raw_body)
-
new(raw_body).filtered_text
-
end
-
-
attr_reader :raw_body
-
-
def initialize(raw_body)
-
@raw_body = raw_body
-
end
-
-
def document
-
@document ||= Nokogiri::HTML(raw_body)
-
end
-
-
def filter_replies!
-
document.xpath('//blockquote').each { |n| n.replace('> ') }
-
end
-
-
def filtered_html
-
@filtered_html ||= begin
-
filter_replies!
-
document.inner_html
-
end
-
end
-
-
def filtered_text
-
@filtered_text ||= Html2Text.convert(filtered_html)
-
end
-
end
-
class Inbox::EventDataPresenter < SimpleDelegator
-
def push_data
-
{
-
# Conversation thread config
-
allow_messages_after_resolved: allow_messages_after_resolved,
-
lock_to_single_conversation: lock_to_single_conversation,
-
-
# Auto Assignment config
-
auto_assignment_config: auto_assignment_config,
-
enable_auto_assignment: enable_auto_assignment,
-
-
# Feature flag for message events
-
enable_email_collect: enable_email_collect,
-
greeting_enabled: greeting_enabled,
-
greeting_message: greeting_message,
-
csat_survey_enabled: csat_survey_enabled,
-
-
# Outbound email sender config
-
business_name: business_name,
-
sender_name_type: sender_name_type,
-
-
# Business hour config
-
timezone: timezone,
-
out_of_office_message: out_of_office_message,
-
working_hours_enabled: working_hours_enabled,
-
working_hours: working_hours,
-
-
created_at: created_at,
-
updated_at: updated_at,
-
-
# Associated channel attributes
-
channel: channel
-
}
-
end
-
end
-
class MailPresenter < SimpleDelegator
-
attr_accessor :mail
-
-
def initialize(mail, account = nil)
-
super(mail)
-
@mail = mail
-
@account = account
-
end
-
-
def subject
-
encode_to_unicode(@mail.subject)
-
end
-
-
# encode decoded mail text_part or html_part if mail is multipart email
-
# encode decoded mail raw bodyt if mail is not multipart email but the body content is text/html
-
def mail_content(mail_part)
-
if multipart_mail_body?
-
decoded_multipart_mail(mail_part)
-
else
-
text_html_mail(mail_part)
-
end
-
end
-
-
# encodes mail if mail.parts is present
-
# encodes mail content type is multipart
-
def decoded_multipart_mail(mail_part)
-
encoded = encode_to_unicode(mail_part&.decoded)
-
-
encoded if text_mail_body? || html_mail_body?
-
end
-
-
# encodes mail raw body if mail.parts is empty
-
# encodes mail raw body if mail.content_type is plain/text
-
# encodes mail raw body if mail.content_type is html/text
-
def text_html_mail(mail_part)
-
decoded = mail_part&.decoded || @mail.decoded
-
encoded = encode_to_unicode(decoded)
-
-
encoded if html_mail_body? || text_mail_body?
-
end
-
-
def text_content
-
@decoded_text_content = mail_content(text_part) || ''
-
-
encoding = @decoded_text_content.encoding
-
-
body = EmailReplyTrimmer.trim(@decoded_text_content)
-
-
return {} if @decoded_text_content.blank? || !text_mail_body?
-
-
@text_content ||= {
-
full: mail_content(text_part),
-
reply: @decoded_text_content,
-
quoted: body.force_encoding(encoding).encode('UTF-8')
-
}
-
end
-
-
def html_content
-
encoded = mail_content(html_part) || ''
-
@decoded_html_content = ::HtmlParser.parse_reply(encoded)
-
-
return {} if @decoded_html_content.blank? || !html_mail_body?
-
-
body = EmailReplyTrimmer.trim(@decoded_html_content)
-
-
@html_content ||= {
-
full: mail_content(html_part),
-
reply: @decoded_html_content,
-
quoted: body
-
}
-
end
-
-
# check content disposition check
-
# if inline, upload to AWS and and take the URL
-
def attachments
-
# ref : https://github.com/gorails-screencasts/action-mailbox-action-text/blob/master/app/mailboxes/posts_mailbox.rb
-
mail.attachments.map do |attachment|
-
blob = ActiveStorage::Blob.create_and_upload!(
-
io: StringIO.new(attachment.body.to_s),
-
filename: attachment.filename.presence || "attachment_#{SecureRandom.hex(4)}",
-
content_type: attachment.content_type
-
)
-
{ original: attachment, blob: blob }
-
end
-
end
-
-
def number_of_attachments
-
mail.attachments.count
-
end
-
-
def serialized_data
-
{
-
bcc: bcc,
-
cc: cc,
-
content_type: content_type,
-
date: date,
-
from: from,
-
html_content: html_content,
-
in_reply_to: in_reply_to,
-
message_id: message_id,
-
multipart: multipart?,
-
number_of_attachments: number_of_attachments,
-
subject: subject,
-
text_content: text_content,
-
to: to
-
}
-
end
-
-
def in_reply_to
-
return if @mail.in_reply_to.blank?
-
-
# Although the "in_reply_to" field in the email can potentially hold multiple values,
-
# our current system does not have the capability to handle this.
-
# FIX ME: Address this issue by returning the complete results and utilizing them for querying conversations.
-
@mail.in_reply_to.is_a?(Array) ? @mail.in_reply_to.first : @mail.in_reply_to
-
end
-
-
def from
-
# changing to downcase to avoid case mismatch while finding contact
-
(@mail.reply_to.presence || @mail.from).map(&:downcase)
-
end
-
-
def sender_name
-
Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
-
end
-
-
def original_sender
-
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
-
end
-
-
def from_email_address(email)
-
Mail::Address.new(email).address
-
end
-
-
def email_forwarded_for
-
@mail['X-Forwarded-For'].try(:value)
-
end
-
-
def mail_receiver
-
if @mail.to.blank?
-
return [email_forwarded_for] if email_forwarded_for.present?
-
-
[]
-
else
-
@mail.to
-
end
-
end
-
-
def auto_reply?
-
auto_submitted? || x_auto_reply?
-
end
-
-
def notification_email_from_chatwoot?
-
# notification emails are send via mailer sender email address. so it should match
-
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
-
end
-
-
private
-
-
def auto_submitted?
-
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
-
end
-
-
def x_auto_reply?
-
@mail['X-Autoreply'].present? && @mail['X-Autoreply'].value == 'yes'
-
end
-
-
# forcing the encoding of the content to UTF-8 so as to be compatible with database and serializers
-
def encode_to_unicode(str)
-
return '' if str.blank?
-
-
current_encoding = str.encoding.name
-
return str if current_encoding == 'UTF-8'
-
-
str.encode(current_encoding, 'UTF-8', invalid: :replace, undef: :replace, replace: '?')
-
rescue StandardError
-
''
-
end
-
-
def html_mail_body?
-
((mail.content_type || '').include? 'text/html') || @mail.html_part&.content_type&.include?('text/html')
-
end
-
-
def text_mail_body?
-
((mail.content_type || '').include? 'text/plain') || @mail.text_part&.content_type&.include?('text/plain')
-
end
-
-
def multipart_mail_body?
-
((mail.content_type || '').include? 'multipart') || @mail.parts.any?
-
end
-
end
-
class Reports::TimeFormatPresenter
-
include ActionView::Helpers::TextHelper
-
-
attr_reader :seconds
-
-
def initialize(seconds = nil)
-
@seconds = seconds.to_i if seconds.present?
-
end
-
-
def format
-
return 'N/A' if seconds.nil? || seconds.zero?
-
-
days, remainder = seconds.divmod(86_400)
-
hours, remainder = remainder.divmod(3600)
-
minutes, seconds = remainder.divmod(60)
-
-
format_components(days: days, hours: hours, minutes: minutes, seconds: seconds)
-
end
-
-
private
-
-
def format_components(components)
-
formatted_components = components.filter_map do |unit, value|
-
next if value.zero?
-
-
I18n.t("time_units.#{unit}", count: value)
-
end
-
-
return I18n.t('time_units.seconds', count: 0) if formatted_components.empty?
-
-
formatted_components.first(2).join(' ')
-
end
-
end
-
class ActionService
-
include EmailHelper
-
-
def initialize(conversation)
-
@conversation = conversation.reload
-
@account = @conversation.account
-
end
-
-
def mute_conversation(_params)
-
@conversation.mute!
-
end
-
-
def snooze_conversation(_params)
-
@conversation.snoozed!
-
end
-
-
def resolve_conversation(_params)
-
@conversation.resolved!
-
end
-
-
def change_status(status)
-
@conversation.update!(status: status[0])
-
end
-
-
def change_priority(priority)
-
@conversation.update!(priority: (priority[0] == 'nil' ? nil : priority[0]))
-
end
-
-
def add_label(labels)
-
return if labels.empty?
-
-
@conversation.reload.add_labels(labels)
-
end
-
-
def assign_agent(agent_ids = [])
-
return @conversation.update!(assignee_id: nil) if agent_ids[0] == 'nil'
-
-
return unless agent_belongs_to_inbox?(agent_ids)
-
-
@agent = @account.users.find_by(id: agent_ids)
-
-
@conversation.update!(assignee_id: @agent.id) if @agent.present?
-
end
-
-
def remove_label(labels)
-
return if labels.empty?
-
-
labels = @conversation.label_list - labels
-
@conversation.update(label_list: labels)
-
end
-
-
def assign_team(team_ids = [])
-
# FIXME: The explicit checks for zero or nil (string) is bad. Move
-
# this to a separate unassign action.
-
should_unassign = team_ids.blank? || %w[nil 0].include?(team_ids[0].to_s)
-
return @conversation.update!(team_id: nil) if should_unassign
-
-
# check if team belongs to account only if team_id is present
-
# if team_id is nil, then it means that the team is being unassigned
-
return unless !team_ids[0].nil? && team_belongs_to_account?(team_ids)
-
-
@conversation.update!(team_id: team_ids[0])
-
end
-
-
def remove_assigned_team(_params)
-
@conversation.update!(team_id: nil)
-
end
-
-
def send_email_transcript(emails)
-
emails = emails[0].gsub(/\s+/, '').split(',')
-
-
emails.each do |email|
-
email = parse_email_variables(@conversation, email)
-
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later
-
end
-
end
-
-
private
-
-
def agent_belongs_to_inbox?(agent_ids)
-
member_ids = @conversation.inbox.members.pluck(:user_id)
-
assignable_agent_ids = member_ids + @account.administrators.ids
-
-
assignable_agent_ids.include?(agent_ids[0])
-
end
-
-
def team_belongs_to_account?(team_ids)
-
@account.team_ids.include?(team_ids[0])
-
end
-
-
def conversation_a_tweet?
-
return false if @conversation.additional_attributes.blank?
-
-
@conversation.additional_attributes['type'] == 'tweet'
-
end
-
end
-
-
ActionService.include_mod_with('ActionService')
-
1
class AutoAssignment::AgentAssignmentService
-
# Allowed agent ids: array
-
# This is the list of agents from which an agent can be assigned to this conversation
-
# examples: Agents with assignment capacity, Agents who are members of a team etc
-
1
pattr_initialize [:conversation!, :allowed_agent_ids!]
-
-
1
def find_assignee
-
3
round_robin_manage_service.available_agent(allowed_agent_ids: allowed_online_agent_ids)
-
end
-
-
1
def perform
-
3
new_assignee = find_assignee
-
3
conversation.update(assignee: new_assignee) if new_assignee
-
end
-
-
1
private
-
-
1
def online_agent_ids
-
3
online_agents = OnlineStatusTracker.get_available_users(conversation.account_id)
-
3
online_agents.select { |_key, value| value.eql?('online') }.keys if online_agents.present?
-
end
-
-
1
def allowed_online_agent_ids
-
# We want to perform roundrobin only over online agents
-
# Hence taking an intersection of online agents and allowed member ids
-
-
# the online user ids are string, since its from redis, allowed member ids are integer, since its from active record
-
3
@allowed_online_agent_ids ||= online_agent_ids & allowed_agent_ids&.map(&:to_s)
-
end
-
-
1
def round_robin_manage_service
-
3
@round_robin_manage_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: conversation.inbox)
-
end
-
-
1
def round_robin_key
-
format(::Redis::Alfred::ROUND_ROBIN_AGENTS, inbox_id: conversation.inbox_id)
-
end
-
end
-
1
class AutoAssignment::InboxRoundRobinService
-
1
pattr_initialize [:inbox!]
-
-
# called on inbox delete
-
1
def clear_queue
-
::Redis::Alfred.delete(round_robin_key)
-
end
-
-
# called on inbox member create
-
1
def add_agent_to_queue(user_id)
-
::Redis::Alfred.lpush(round_robin_key, user_id)
-
end
-
-
# called on inbox member delete
-
1
def remove_agent_from_queue(user_id)
-
::Redis::Alfred.lrem(round_robin_key, user_id)
-
end
-
-
1
def reset_queue
-
clear_queue
-
add_agent_to_queue(inbox.inbox_members.map(&:user_id))
-
end
-
-
# end of queue management functions
-
-
# allowed member ids = [assignable online agents supplied by the assignment service]
-
# the values of allowed member ids should be in string format
-
1
def available_agent(allowed_agent_ids: [])
-
3
reset_queue unless validate_queue?
-
3
user_id = get_member_from_allowed_agent_ids(allowed_agent_ids)
-
3
inbox.inbox_members.find_by(user_id: user_id)&.user if user_id.present?
-
end
-
-
1
private
-
-
1
def get_member_from_allowed_agent_ids(allowed_agent_ids)
-
3
return nil if allowed_agent_ids.blank?
-
-
user_id = queue.intersection(allowed_agent_ids).pop
-
pop_push_to_queue(user_id)
-
user_id
-
end
-
-
1
def pop_push_to_queue(user_id)
-
return if user_id.blank?
-
-
remove_agent_from_queue(user_id)
-
add_agent_to_queue(user_id)
-
end
-
-
1
def validate_queue?
-
3
return true if inbox.inbox_members.map(&:user_id).sort == queue.map(&:to_i).sort
-
end
-
-
1
def queue
-
3
::Redis::Alfred.lrange(round_robin_key)
-
end
-
-
1
def round_robin_key
-
3
format(::Redis::Alfred::ROUND_ROBIN_AGENTS, inbox_id: inbox.id)
-
end
-
end
-
class AutomationRules::ActionService < ActionService
-
def initialize(rule, account, conversation)
-
super(conversation)
-
@rule = rule
-
@account = account
-
Current.executed_by = rule
-
end
-
-
def perform
-
@rule.actions.each do |action|
-
@conversation.reload
-
action = action.with_indifferent_access
-
begin
-
send(action[:action_name], action[:action_params])
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @account).capture_exception
-
end
-
end
-
ensure
-
Current.reset
-
end
-
-
private
-
-
def send_attachment(blob_ids)
-
return if conversation_a_tweet?
-
-
return unless @rule.files.attached?
-
-
blobs = ActiveStorage::Blob.where(id: blob_ids)
-
-
return if blobs.blank?
-
-
params = { content: nil, private: false, attachments: blobs }
-
Messages::MessageBuilder.new(nil, @conversation, params).perform
-
end
-
-
def send_webhook_event(webhook_url)
-
payload = @conversation.webhook_data.merge(event: "automation_event.#{@rule.event_name}")
-
WebhookJob.perform_later(webhook_url[0], payload)
-
end
-
-
def send_message(message)
-
return if conversation_a_tweet?
-
-
params = { content: message[0], private: false, content_attributes: { automation_rule_id: @rule.id } }
-
Messages::MessageBuilder.new(nil, @conversation, params).perform
-
end
-
-
def send_email_to_team(params)
-
teams = Team.where(id: params[0][:team_ids])
-
-
teams.each do |team|
-
TeamNotifications::AutomationNotificationMailer.conversation_creation(@conversation, team, params[0][:message])&.deliver_now
-
end
-
end
-
end
-
class AutomationRules::ConditionValidationService
-
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
-
-
def initialize(rule)
-
@rule = rule
-
@account = rule.account
-
-
file = File.read('./lib/filters/filter_keys.yml')
-
@filters = YAML.safe_load(file)
-
-
@conversation_filters = @filters['conversations']
-
@contact_filters = @filters['contacts']
-
@message_filters = @filters['messages']
-
end
-
-
def perform
-
@rule.conditions.each do |condition|
-
return false unless valid_condition?(condition) && valid_query_operator?(condition)
-
end
-
-
true
-
end
-
-
private
-
-
def valid_query_operator?(condition)
-
query_operator = condition['query_operator']
-
-
return true if query_operator.nil?
-
return true if query_operator.empty?
-
-
%w[AND OR].include?(query_operator.upcase)
-
end
-
-
def valid_condition?(condition)
-
key = condition['attribute_key']
-
-
conversation_filter = @conversation_filters[key]
-
contact_filter = @contact_filters[key]
-
message_filter = @message_filters[key]
-
-
if conversation_filter || contact_filter || message_filter
-
operation_valid?(condition, conversation_filter || contact_filter || message_filter)
-
else
-
custom_attribute_present?(key, condition['custom_attribute_type'])
-
end
-
end
-
-
def operation_valid?(condition, filter)
-
filter_operator = condition['filter_operator']
-
-
# attribute changed is a special case
-
return true if filter_operator == 'attribute_changed'
-
-
filter['filter_operators'].include?(filter_operator)
-
end
-
-
def custom_attribute_present?(attribute_key, attribute_model)
-
attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL
-
-
@account.custom_attribute_definitions.where(
-
attribute_model: attribute_model
-
).find_by(attribute_key: attribute_key).present?
-
end
-
end
-
require 'json'
-
-
class AutomationRules::ConditionsFilterService < FilterService
-
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
-
-
def initialize(rule, conversation = nil, options = {})
-
super([], nil)
-
# assign rule, conversation and account to instance variables
-
@rule = rule
-
@conversation = conversation
-
@account = conversation.account
-
-
# setup filters from json file
-
file = File.read('./lib/filters/filter_keys.yml')
-
@filters = YAML.safe_load(file)
-
-
@conversation_filters = @filters['conversations']
-
@contact_filters = @filters['contacts']
-
@message_filters = @filters['messages']
-
-
@options = options
-
@changed_attributes = options[:changed_attributes]
-
end
-
-
def perform
-
return false unless rule_valid?
-
-
@attribute_changed_query_filter = []
-
-
@rule.conditions.each_with_index do |query_hash, current_index|
-
@attribute_changed_query_filter << query_hash and next if query_hash['filter_operator'] == 'attribute_changed'
-
-
apply_filter(query_hash, current_index)
-
end
-
-
records = base_relation.where(@query_string, @filter_values.with_indifferent_access)
-
records = perform_attribute_changed_filter(records) if @attribute_changed_query_filter.any?
-
-
records.any?
-
rescue StandardError => e
-
Rails.logger.error "Error in AutomationRules::ConditionsFilterService: #{e.message}"
-
Rails.logger.info "AutomationRules::ConditionsFilterService failed while processing rule #{@rule.id} for conversation #{@conversation.id}"
-
false
-
end
-
-
def rule_valid?
-
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
-
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
-
@rule.authorization_error! unless is_valid
-
-
is_valid
-
end
-
-
def filter_operation(query_hash, current_index)
-
if query_hash[:filter_operator] == 'starts_with'
-
@filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%"
-
like_filter_string(query_hash[:filter_operator], current_index)
-
else
-
super
-
end
-
end
-
-
def apply_filter(query_hash, current_index)
-
conversation_filter = @conversation_filters[query_hash['attribute_key']]
-
contact_filter = @contact_filters[query_hash['attribute_key']]
-
message_filter = @message_filters[query_hash['attribute_key']]
-
-
if conversation_filter
-
@query_string += conversation_query_string('conversations', conversation_filter, query_hash.with_indifferent_access, current_index)
-
elsif contact_filter
-
@query_string += contact_query_string(contact_filter, query_hash.with_indifferent_access, current_index)
-
elsif message_filter
-
@query_string += message_query_string(message_filter, query_hash.with_indifferent_access, current_index)
-
elsif custom_attribute(query_hash['attribute_key'], @account, query_hash['custom_attribute_type'])
-
# send table name according to attribute key right now we are supporting contact based custom attribute filter
-
@query_string += custom_attribute_query(query_hash.with_indifferent_access, query_hash['custom_attribute_type'], current_index)
-
end
-
end
-
-
# If attribute_changed type filter is present perform this against array
-
def perform_attribute_changed_filter(records)
-
@attribute_changed_records = []
-
current_attribute_changed_record = base_relation
-
filter_based_on_attribute_change(records, current_attribute_changed_record)
-
-
@attribute_changed_records.uniq
-
end
-
-
# Loop through attribute_changed_query_filter
-
def filter_based_on_attribute_change(records, current_attribute_changed_record)
-
@attribute_changed_query_filter.each do |filter|
-
@changed_attributes = @changed_attributes.with_indifferent_access
-
changed_attribute = @changed_attributes[filter['attribute_key']].presence
-
-
if changed_attribute[0].in?(filter['values']['from']) && changed_attribute[1].in?(filter['values']['to'])
-
@attribute_changed_records = attribute_changed_filter_query(filter, records, current_attribute_changed_record)
-
end
-
current_attribute_changed_record = @attribute_changed_records
-
end
-
end
-
-
# We intersect with the record if query_operator-AND is present and union if query_operator-OR is present
-
def attribute_changed_filter_query(filter, records, current_attribute_changed_record)
-
if filter['query_operator'] == 'AND'
-
@attribute_changed_records + (current_attribute_changed_record & records)
-
else
-
@attribute_changed_records + (current_attribute_changed_record | records)
-
end
-
end
-
-
def message_query_string(current_filter, query_hash, current_index)
-
attribute_key = query_hash['attribute_key']
-
query_operator = query_hash['query_operator']
-
-
attribute_key = 'processed_message_content' if attribute_key == 'content'
-
-
filter_operator_value = filter_operation(query_hash, current_index)
-
-
case current_filter['attribute_type']
-
when 'standard'
-
if current_filter['data_type'] == 'text'
-
" LOWER(messages.#{attribute_key}) #{filter_operator_value} #{query_operator} "
-
else
-
" messages.#{attribute_key} #{filter_operator_value} #{query_operator} "
-
end
-
end
-
end
-
-
# This will be used in future for contact automation rule
-
def contact_query_string(current_filter, query_hash, current_index)
-
attribute_key = query_hash['attribute_key']
-
query_operator = query_hash['query_operator']
-
-
filter_operator_value = filter_operation(query_hash, current_index)
-
-
case current_filter['attribute_type']
-
when 'additional_attributes'
-
" contacts.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
-
when 'standard'
-
" contacts.#{attribute_key} #{filter_operator_value} #{query_operator} "
-
end
-
end
-
-
def conversation_query_string(table_name, current_filter, query_hash, current_index)
-
attribute_key = query_hash['attribute_key']
-
query_operator = query_hash['query_operator']
-
filter_operator_value = filter_operation(query_hash, current_index)
-
-
case current_filter['attribute_type']
-
when 'additional_attributes'
-
" #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
-
when 'standard'
-
if attribute_key == 'labels'
-
" tags.id #{filter_operator_value} #{query_operator} "
-
else
-
" #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
-
end
-
end
-
end
-
-
private
-
-
def base_relation
-
records = Conversation.where(id: @conversation.id).joins(
-
'LEFT OUTER JOIN contacts on conversations.contact_id = contacts.id'
-
).joins(
-
'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id'
-
)
-
records = records.where(messages: { id: @options[:message].id }) if @options[:message].present?
-
records
-
end
-
end
-
#######################################
-
# To create an external channel reply service
-
# - Inherit this as the base class.
-
# - Implement `channel_class` method in your child class.
-
# - Implement `perform_reply` method in your child class.
-
# - Implement additional custom logic for your `perform_reply` method.
-
# - When required override the validation_methods.
-
# - Use Childclass.new.perform.
-
######################################
-
class Base::SendOnChannelService
-
pattr_initialize [:message!]
-
-
def perform
-
validate_target_channel
-
return unless outgoing_message?
-
return if invalid_message?
-
-
perform_reply
-
end
-
-
private
-
-
delegate :conversation, to: :message
-
delegate :contact, :contact_inbox, :inbox, to: :conversation
-
delegate :channel, to: :inbox
-
-
def channel_class
-
raise 'Overwrite this method in child class'
-
end
-
-
def perform_reply
-
raise 'Overwrite this method in child class'
-
end
-
-
def outgoing_message_originated_from_channel?
-
# TODO: we need to refactor this logic as more integrations comes by
-
# chatwoot messages won't have source id at the moment
-
# TODO: migrate source_ids to external_source_ids and check the source id relevant to specific channel
-
message.source_id.present?
-
end
-
-
def outgoing_message?
-
message.outgoing? || message.template?
-
end
-
-
def invalid_message?
-
# private notes aren't send to the channels
-
# we should also avoid the case of message loops, when outgoing messages are created from channel
-
message.private? || outgoing_message_originated_from_channel?
-
end
-
-
def validate_target_channel
-
raise 'Invalid channel service was called' if inbox.channel.class != channel_class
-
end
-
end
-
class BaseRefreshOauthTokenService
-
pattr_initialize [:channel!]
-
-
# Additional references: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
-
def access_token
-
return provider_config[:access_token] unless access_token_expired?
-
-
refreshed_tokens = refresh_tokens
-
refreshed_tokens[:access_token]
-
end
-
-
def access_token_expired?
-
expiry = provider_config[:expires_on]
-
-
return true if expiry.blank?
-
-
# Adding a 5 minute window to expiry check to avoid any race
-
# conditions during the fetch operation. This would assure that the
-
# tokens are updated when we fetch the emails.
-
Time.current.utc >= DateTime.parse(expiry) - 5.minutes
-
end
-
-
# Refresh the access tokens using the refresh token
-
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
-
def refresh_tokens
-
oauth_strategy = build_oauth_strategy
-
token_service = build_token_service(oauth_strategy)
-
-
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
-
-
update_channel_provider_config(new_tokens)
-
channel.reload.provider_config
-
end
-
-
def update_channel_provider_config(new_tokens)
-
channel.provider_config = {
-
access_token: new_tokens[:access_token],
-
refresh_token: new_tokens[:refresh_token],
-
expires_on: Time.at(new_tokens[:expires_at]).utc.to_s
-
}
-
channel.save!
-
end
-
-
private
-
-
def build_oauth_strategy
-
raise NotImplementedError
-
end
-
-
def provider_config
-
@provider_config ||= channel.provider_config.with_indifferent_access
-
end
-
-
# Builds the token service using OAuth2
-
def build_token_service(oauth_strategy)
-
OAuth2::AccessToken.new(
-
oauth_strategy.client,
-
provider_config[:access_token],
-
refresh_token: provider_config[:refresh_token]
-
)
-
end
-
end
-
class Contacts::ContactableInboxesService
-
pattr_initialize [:contact!]
-
-
def get
-
account = contact.account
-
account.inboxes.filter_map { |inbox| get_contactable_inbox(inbox) }
-
end
-
-
private
-
-
def get_contactable_inbox(inbox)
-
case inbox.channel_type
-
when 'Channel::TwilioSms'
-
twilio_contactable_inbox(inbox)
-
when 'Channel::Whatsapp'
-
whatsapp_contactable_inbox(inbox)
-
when 'Channel::Sms'
-
sms_contactable_inbox(inbox)
-
when 'Channel::Email'
-
email_contactable_inbox(inbox)
-
when 'Channel::Api'
-
api_contactable_inbox(inbox)
-
when 'Channel::WebWidget'
-
website_contactable_inbox(inbox)
-
end
-
end
-
-
def website_contactable_inbox(inbox)
-
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
-
return unless latest_contact_inbox
-
# FIXME : change this when multiple conversations comes in
-
return if latest_contact_inbox.conversations.present?
-
-
{ source_id: latest_contact_inbox.source_id, inbox: inbox }
-
end
-
-
def api_contactable_inbox(inbox)
-
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
-
source_id = latest_contact_inbox&.source_id || SecureRandom.uuid
-
-
{ source_id: source_id, inbox: inbox }
-
end
-
-
def email_contactable_inbox(inbox)
-
return if @contact.email.blank?
-
-
{ source_id: @contact.email, inbox: inbox }
-
end
-
-
def whatsapp_contactable_inbox(inbox)
-
return if @contact.phone_number.blank?
-
-
# Remove the plus since thats the format 360 dialog uses
-
{ source_id: @contact.phone_number.delete('+'), inbox: inbox }
-
end
-
-
def sms_contactable_inbox(inbox)
-
return if @contact.phone_number.blank?
-
-
{ source_id: @contact.phone_number, inbox: inbox }
-
end
-
-
def twilio_contactable_inbox(inbox)
-
return if @contact.phone_number.blank?
-
-
case inbox.channel.medium
-
when 'sms'
-
{ source_id: @contact.phone_number, inbox: inbox }
-
when 'whatsapp'
-
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
-
end
-
end
-
end
-
class Contacts::FilterService < FilterService
-
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
-
-
def initialize(account, user, params)
-
@account = account
-
# TODO: Change the order of arguments in FilterService maybe?
-
# account, user, params makes more sense
-
super(params, user)
-
end
-
-
def perform
-
validate_query_operator
-
@contacts = query_builder(@filters['contacts'])
-
-
{
-
contacts: @contacts,
-
count: @contacts.count
-
}
-
end
-
-
def filter_values(query_hash)
-
current_val = query_hash['values'][0]
-
if query_hash['attribute_key'] == 'phone_number'
-
"+#{current_val}"
-
elsif query_hash['attribute_key'] == 'country_code'
-
current_val.downcase
-
else
-
current_val.is_a?(String) ? current_val.downcase : current_val
-
end
-
end
-
-
# TODO: @account.contacts.resolved_contacts ? to stay consistant with the behavior in ui
-
def base_relation
-
@account.contacts
-
end
-
-
def filter_config
-
{
-
entity: 'Contact',
-
table_name: 'contacts'
-
}
-
end
-
-
private
-
-
def equals_to_filter_string(filter_operator, current_index)
-
return "= :value_#{current_index}" if filter_operator == 'equal_to'
-
-
"!= :value_#{current_index}"
-
end
-
end
-
1
class Contacts::SyncAttributes
-
1
attr_reader :contact
-
-
1
def initialize(contact)
-
9
@contact = contact
-
end
-
-
1
def perform
-
9
update_contact_location_and_country_code
-
9
set_contact_type
-
end
-
-
1
private
-
-
1
def update_contact_location_and_country_code
-
# Ensure that location and country_code are updated from additional_attributes.
-
# TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
-
9
@contact.location = @contact.additional_attributes['city']
-
9
@contact.country_code = @contact.additional_attributes['country']
-
end
-
-
1
def set_contact_type
-
# If the contact is already a lead or customer then do not change the contact type
-
9
return unless @contact.contact_type == 'visitor'
-
# If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead
-
# If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead
-
9
return unless @contact.email.present? || @contact.phone_number.present? || social_details_present?
-
-
3
@contact.contact_type = 'lead'
-
end
-
-
1
def social_details_present?
-
6
@contact.additional_attributes.keys.any? do |key|
-
key.start_with?('social_') && @contact.additional_attributes[key].present?
-
end
-
end
-
end
-
class Conversations::FilterService < FilterService
-
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
-
-
def initialize(params, user, account)
-
@account = account
-
super(params, user)
-
end
-
-
def perform
-
validate_query_operator
-
@conversations = query_builder(@filters['conversations'])
-
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
-
assigned_count = all_count - unassigned_count
-
-
{
-
conversations: conversations,
-
count: {
-
mine_count: mine_count,
-
assigned_count: assigned_count,
-
unassigned_count: unassigned_count,
-
all_count: all_count
-
}
-
}
-
end
-
-
def base_relation
-
conversations = @account.conversations.includes(
-
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
-
)
-
-
account_user = @account.account_users.find_by(user_id: @user.id)
-
is_administrator = account_user&.role == 'administrator'
-
-
# Ensure we only include conversations from inboxes the user has access to
-
unless is_administrator
-
inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id)
-
conversations = conversations.where(inbox_id: inbox_ids)
-
end
-
-
# Apply permission-based filtering
-
Conversations::PermissionFilterService.new(
-
conversations,
-
@user,
-
@account
-
).perform
-
end
-
-
def current_page
-
@params[:page] || 1
-
end
-
-
def filter_config
-
{
-
entity: 'Conversation',
-
table_name: 'conversations'
-
}
-
end
-
-
def conversations
-
@conversations.sort_on_last_activity_at.page(current_page)
-
end
-
end
-
1
class Conversations::MessageWindowService
-
1
MESSAGING_WINDOW_24_HOURS = 24.hours
-
1
MESSAGING_WINDOW_7_DAYS = 7.days
-
-
1
def initialize(conversation)
-
12
@conversation = conversation
-
end
-
-
1
def can_reply?
-
12
return true if messaging_window.blank?
-
-
last_message_in_messaging_window?(messaging_window)
-
end
-
-
1
private
-
-
1
def messaging_window
-
12
case @conversation.inbox.channel_type
-
when 'Channel::Api'
-
api_messaging_window
-
when 'Channel::FacebookPage'
-
messenger_messaging_window
-
when 'Channel::Instagram'
-
instagram_messaging_window
-
when 'Channel::Whatsapp'
-
MESSAGING_WINDOW_24_HOURS
-
when 'Channel::TwilioSms'
-
twilio_messaging_window
-
end
-
end
-
-
1
def last_message_in_messaging_window?(time)
-
return false if last_incoming_message.nil?
-
-
Time.current < last_incoming_message.created_at + time
-
end
-
-
1
def api_messaging_window
-
return if @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].blank?
-
-
@conversation.inbox.channel.additional_attributes['agent_reply_time_window'].to_i.hours
-
end
-
-
# Check medium of the inbox to determine the messaging window
-
1
def twilio_messaging_window
-
@conversation.inbox.channel.medium == 'whatsapp' ? MESSAGING_WINDOW_24_HOURS : nil
-
end
-
-
1
def messenger_messaging_window
-
meta_messaging_window('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
-
end
-
-
1
def instagram_messaging_window
-
meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
-
end
-
-
1
def meta_messaging_window(config_key)
-
GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS
-
end
-
-
1
def last_incoming_message
-
@last_incoming_message ||= @conversation.messages&.incoming&.last
-
end
-
end
-
class Conversations::PermissionFilterService
-
attr_reader :conversations, :user, :account
-
-
def initialize(conversations, user, account)
-
@conversations = conversations
-
@user = user
-
@account = account
-
end
-
-
def perform
-
# The base implementation simply returns all conversations
-
# Enterprise edition extends this with permission-based filtering
-
conversations
-
end
-
end
-
-
Conversations::PermissionFilterService.prepend_mod_with('Conversations::PermissionFilterService')
-
class Conversations::TypingStatusManager
-
include Events::Types
-
-
attr_reader :conversation, :user, :params
-
-
def initialize(conversation, user, params)
-
@conversation = conversation
-
@user = user
-
@params = params
-
end
-
-
def trigger_typing_event(event, is_private)
-
user = @user.presence || @resource
-
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
-
end
-
-
def toggle_typing_status
-
case params[:typing_status]
-
when 'on'
-
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
-
when 'off'
-
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
-
end
-
# Return the head :ok response from the controller
-
end
-
end
-
class Crm::BaseProcessorService
-
def initialize(hook)
-
@hook = hook
-
@account = hook.account
-
end
-
-
# Class method to be overridden by subclasses
-
def self.crm_name
-
raise NotImplementedError, 'Subclasses must define self.crm_name'
-
end
-
-
# Instance method that calls the class method
-
def crm_name
-
self.class.crm_name
-
end
-
-
def process_event(event_name, event_data)
-
case event_name
-
when 'contact.created'
-
handle_contact_created(event_data)
-
when 'contact.updated'
-
handle_contact_updated(event_data)
-
when 'conversation.created'
-
handle_conversation_created(event_data)
-
when 'conversation.updated'
-
handle_conversation_updated(event_data)
-
else
-
{ success: false, error: "Unsupported event: #{event_name}" }
-
end
-
rescue StandardError => e
-
Rails.logger.error "#{crm_name} Processor Error: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
{ success: false, error: e.message }
-
end
-
-
# Abstract methods that subclasses must implement
-
def handle_contact_created(contact)
-
raise NotImplementedError, 'Subclasses must implement #handle_contact_created'
-
end
-
-
def handle_contact_updated(contact)
-
raise NotImplementedError, 'Subclasses must implement #handle_contact_updated'
-
end
-
-
def handle_conversation_created(conversation)
-
raise NotImplementedError, 'Subclasses must implement #handle_conversation_created'
-
end
-
-
def handle_conversation_resolved(conversation)
-
raise NotImplementedError, 'Subclasses must implement #handle_conversation_resolved'
-
end
-
-
# Common helper methods for all CRM processors
-
-
protected
-
-
def identifiable_contact?(contact)
-
has_social_profile = contact.additional_attributes['social_profiles'].present?
-
contact.present? && (contact.email.present? || contact.phone_number.present? || has_social_profile)
-
end
-
-
def get_external_id(contact)
-
return nil if contact.additional_attributes.blank?
-
return nil if contact.additional_attributes['external'].blank?
-
-
contact.additional_attributes.dig('external', "#{crm_name}_id")
-
end
-
-
def store_external_id(contact, external_id)
-
# Initialize additional_attributes if it's nil
-
contact.additional_attributes = {} if contact.additional_attributes.nil?
-
-
# Initialize external hash if it doesn't exist
-
contact.additional_attributes['external'] = {} if contact.additional_attributes['external'].blank?
-
-
# Store the external ID
-
contact.additional_attributes['external']["#{crm_name}_id"] = external_id
-
contact.save!
-
end
-
-
def store_conversation_metadata(conversation, metadata)
-
# Initialize additional_attributes if it's nil
-
conversation.additional_attributes = {} if conversation.additional_attributes.nil?
-
-
# Initialize CRM-specific hash in additional_attributes
-
conversation.additional_attributes[crm_name] = {} if conversation.additional_attributes[crm_name].blank?
-
-
# Store the metadata
-
conversation.additional_attributes[crm_name].merge!(metadata)
-
conversation.save!
-
end
-
end
-
class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
-
# https://apidocs.leadsquared.com/post-an-activity-to-lead/#api
-
def post_activity(prospect_id, activity_event, activity_note)
-
raise ArgumentError, 'Prospect ID is required' if prospect_id.blank?
-
raise ArgumentError, 'Activity event code is required' if activity_event.blank?
-
-
path = 'ProspectActivity.svc/Create'
-
-
body = {
-
'RelatedProspectId' => prospect_id,
-
'ActivityEvent' => activity_event,
-
'ActivityNote' => activity_note
-
}
-
-
response = post(path, {}, body)
-
response['Message']['Id']
-
end
-
-
def create_activity_type(name:, score:, direction: 0)
-
raise ArgumentError, 'Activity name is required' if name.blank?
-
-
path = 'ProspectActivity.svc/CreateType'
-
body = {
-
'ActivityEventName' => name,
-
'Score' => score.to_i,
-
'Direction' => direction.to_i
-
}
-
-
response = post(path, {}, body)
-
response['Message']['Id']
-
end
-
-
def fetch_activity_types
-
get('ProspectActivity.svc/ActivityTypes.Get')
-
end
-
end
-
class Crm::Leadsquared::Api::BaseClient
-
include HTTParty
-
-
class ApiError < StandardError
-
attr_reader :code, :response
-
-
def initialize(message = nil, code = nil, response = nil)
-
@code = code
-
@response = response
-
super(message)
-
end
-
end
-
-
def initialize(access_key, secret_key, endpoint_url)
-
@access_key = access_key
-
@secret_key = secret_key
-
@base_uri = endpoint_url
-
end
-
-
def get(path, params = {})
-
full_url = URI.join(@base_uri, path).to_s
-
-
options = {
-
query: params,
-
headers: headers
-
}
-
-
response = self.class.get(full_url, options)
-
handle_response(response)
-
end
-
-
def post(path, params = {}, body = {})
-
full_url = URI.join(@base_uri, path).to_s
-
-
options = {
-
query: params,
-
headers: headers
-
}
-
-
options[:body] = body.to_json if body.present?
-
-
response = self.class.post(full_url, options)
-
handle_response(response)
-
end
-
-
private
-
-
def headers
-
{
-
'Content-Type': 'application/json',
-
'x-LSQ-AccessKey': @access_key,
-
'x-LSQ-SecretKey': @secret_key
-
}
-
end
-
-
def handle_response(response)
-
case response.code
-
when 200..299
-
handle_success(response)
-
else
-
error_message = "LeadSquared API error: #{response.code} - #{response.body}"
-
Rails.logger.error error_message
-
raise ApiError.new(error_message, response.code, response)
-
end
-
end
-
-
def handle_success(response)
-
parse_response(response)
-
rescue JSON::ParserError, TypeError => e
-
error_message = "Failed to parse LeadSquared API response: #{e.message}"
-
raise ApiError.new(error_message, response.code, response)
-
end
-
-
def parse_response(response)
-
body = response.parsed_response
-
-
if body.is_a?(Hash) && body['Status'] == 'Error'
-
error_message = body['ExceptionMessage'] || 'Unknown API error'
-
raise ApiError.new(error_message, response.code, response)
-
else
-
body
-
end
-
end
-
end
-
class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
-
# https://apidocs.leadsquared.com/quick-search/#api
-
def search_lead(key)
-
raise ArgumentError, 'Search key is required' if key.blank?
-
-
path = 'LeadManagement.svc/Leads.GetByQuickSearch'
-
params = { key: key }
-
-
get(path, params)
-
end
-
-
# https://apidocs.leadsquared.com/create-or-update/#api
-
# The email address and phone fields are used as the default search criteria.
-
# If none of these match with an existing lead, a new lead will be created.
-
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
-
# we don't need this capability at the moment
-
def create_or_update_lead(lead_data)
-
raise ArgumentError, 'Lead data is required' if lead_data.blank?
-
-
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
-
-
formatted_data = format_lead_data(lead_data)
-
response = post(path, {}, formatted_data)
-
-
response['Message']['Id']
-
end
-
-
def update_lead(lead_data, lead_id)
-
raise ArgumentError, 'Lead ID is required' if lead_id.blank?
-
raise ArgumentError, 'Lead data is required' if lead_data.blank?
-
-
path = "LeadManagement.svc/Lead.Update?leadId=#{lead_id}"
-
formatted_data = format_lead_data(lead_data)
-
-
response = post(path, {}, formatted_data)
-
-
response['Message']['AffectedRows']
-
end
-
-
private
-
-
def format_lead_data(lead_data)
-
lead_data.map do |key, value|
-
{
-
'Attribute' => key,
-
'Value' => value
-
}
-
end
-
end
-
end
-
class Crm::Leadsquared::LeadFinderService
-
def initialize(lead_client)
-
@lead_client = lead_client
-
end
-
-
def find_or_create(contact)
-
lead_id = get_stored_id(contact)
-
return lead_id if lead_id.present?
-
-
lead_id = find_by_contact(contact)
-
return lead_id if lead_id.present?
-
-
create_lead(contact)
-
end
-
-
private
-
-
def find_by_contact(contact)
-
lead_id = find_by_email(contact)
-
lead_id = find_by_phone_number(contact) if lead_id.blank?
-
-
lead_id
-
end
-
-
def find_by_email(contact)
-
return if contact.email.blank?
-
-
search_by_field(contact.email)
-
end
-
-
def find_by_phone_number(contact)
-
return if contact.phone_number.blank?
-
-
search_by_field(contact.phone_number)
-
end
-
-
def search_by_field(value)
-
leads = @lead_client.search_lead(value)
-
return nil unless leads.is_a?(Array)
-
-
leads.first['ProspectID'] if leads.any?
-
end
-
-
def create_lead(contact)
-
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
-
lead_id = @lead_client.create_or_update_lead(lead_data)
-
-
raise StandardError, 'Failed to create lead - no ID returned' if lead_id.blank?
-
-
lead_id
-
end
-
-
def get_stored_id(contact)
-
return nil if contact.additional_attributes.blank?
-
return nil if contact.additional_attributes['external'].blank?
-
-
contact.additional_attributes.dig('external', 'leadsquared_id')
-
end
-
end
-
class Crm::Leadsquared::Mappers::ContactMapper
-
def self.map(contact)
-
new(contact).map
-
end
-
-
def initialize(contact)
-
@contact = contact
-
end
-
-
def map
-
base_attributes
-
end
-
-
private
-
-
attr_reader :contact
-
-
def base_attributes
-
{
-
'FirstName' => contact.name.presence,
-
'LastName' => contact.last_name.presence,
-
'EmailAddress' => contact.email.presence,
-
'Mobile' => contact.phone_number.presence,
-
'Source' => brand_name
-
}.compact
-
end
-
-
def brand_name
-
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
-
end
-
-
def brand_name_without_spaces
-
brand_name.gsub(/\s+/, '')
-
end
-
end
-
class Crm::Leadsquared::Mappers::ConversationMapper
-
include ::Rails.application.routes.url_helpers
-
-
# https://help.leadsquared.com/what-is-the-maximum-character-length-supported-for-lead-and-activity-fields/
-
# the rest of the body of the note is around 200 chars
-
# so this limits it
-
ACTIVITY_NOTE_MAX_SIZE = 1800
-
-
def self.map_conversation_activity(conversation)
-
new(conversation).conversation_activity
-
end
-
-
def self.map_transcript_activity(conversation, messages = nil)
-
new(conversation, messages).transcript_activity
-
end
-
-
def initialize(conversation, messages = nil)
-
@conversation = conversation
-
@messages = messages
-
end
-
-
def conversation_activity
-
I18n.t('crm.created_activity',
-
brand_name: brand_name,
-
channel_info: conversation.inbox.name,
-
formatted_creation_time: formatted_creation_time,
-
display_id: conversation.display_id,
-
url: conversation_url)
-
end
-
-
def transcript_activity
-
return I18n.t('crm.no_message') if transcript_messages.empty?
-
-
I18n.t('crm.transcript_activity',
-
brand_name: brand_name,
-
channel_info: conversation.inbox.name,
-
display_id: conversation.display_id,
-
url: conversation_url,
-
format_messages: format_messages)
-
end
-
-
private
-
-
attr_reader :conversation, :messages
-
-
def formatted_creation_time
-
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
-
end
-
-
def transcript_messages
-
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
-
end
-
-
def format_messages
-
selected_messages = []
-
separator = "\n\n"
-
current_length = 0
-
-
# Reverse the messages to have latest on top
-
transcript_messages.reverse_each do |message|
-
formatted_message = format_message(message)
-
required_length = formatted_message.length + separator.length # the last one does not need to account for separator, but we add it anyway
-
-
break unless (current_length + required_length) <= ACTIVITY_NOTE_MAX_SIZE
-
-
selected_messages << formatted_message
-
current_length += required_length
-
end
-
-
selected_messages.join(separator)
-
end
-
-
def format_message(message)
-
<<~MESSAGE.strip
-
[#{message_time(message)}] #{sender_name(message)}: #{message_content(message)}#{attachment_info(message)}
-
MESSAGE
-
end
-
-
def message_time(message)
-
# TODO: Figure out what timezone to send the time in
-
message.created_at.strftime('%Y-%m-%d %H:%M')
-
end
-
-
def sender_name(message)
-
return 'System' if message.sender.nil?
-
-
message.sender.name.presence || "#{message.sender_type} #{message.sender_id}"
-
end
-
-
def message_content(message)
-
message.content.presence || I18n.t('crm.no_content')
-
end
-
-
def attachment_info(message)
-
return '' unless message.attachments.any?
-
-
attachments = message.attachments.map { |a| I18n.t('crm.attachment', type: a.file_type) }.join(', ')
-
"\n#{attachments}"
-
end
-
-
def conversation_url
-
app_account_conversation_url(account_id: conversation.account.id, id: conversation.display_id)
-
end
-
-
def brand_name
-
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
-
end
-
end
-
class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
-
def self.crm_name
-
'leadsquared'
-
end
-
-
def initialize(hook)
-
super(hook)
-
@access_key = hook.settings['access_key']
-
@secret_key = hook.settings['secret_key']
-
@endpoint_url = hook.settings['endpoint_url']
-
-
@allow_transcript = hook.settings['enable_transcript_activity']
-
@allow_conversation = hook.settings['enable_conversation_activity']
-
-
# Initialize API clients
-
@lead_client = Crm::Leadsquared::Api::LeadClient.new(@access_key, @secret_key, @endpoint_url)
-
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, @endpoint_url)
-
@lead_finder = Crm::Leadsquared::LeadFinderService.new(@lead_client)
-
end
-
-
def handle_contact(contact)
-
contact.reload
-
unless identifiable_contact?(contact)
-
Rails.logger.info("Contact not identifiable. Skipping handle_contact for ##{contact.id}")
-
return
-
end
-
-
stored_lead_id = get_external_id(contact)
-
create_or_update_lead(contact, stored_lead_id)
-
end
-
-
def handle_conversation_created(conversation)
-
return unless @allow_conversation
-
-
create_conversation_activity(
-
conversation: conversation,
-
activity_type: 'conversation',
-
activity_code_key: 'conversation_activity_code',
-
metadata_key: 'created_activity_id',
-
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation)
-
)
-
end
-
-
def handle_conversation_resolved(conversation)
-
return unless @allow_transcript
-
return unless conversation.status == 'resolved'
-
-
create_conversation_activity(
-
conversation: conversation,
-
activity_type: 'transcript',
-
activity_code_key: 'transcript_activity_code',
-
metadata_key: 'transcript_activity_id',
-
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation)
-
)
-
end
-
-
private
-
-
def create_or_update_lead(contact, lead_id)
-
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
-
-
# Why can't we use create_or_update_lead here?
-
# In LeadSquared, it's possible that the email field
-
# may not be marked as unique, same with the phone number field
-
# So we just use the update API if we already have a lead ID
-
if lead_id.present?
-
@lead_client.update_lead(lead_data, lead_id)
-
else
-
new_lead_id = @lead_client.create_or_update_lead(lead_data)
-
store_external_id(contact, new_lead_id)
-
end
-
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
-
ChatwootExceptionTracker.new(e, account: @account).capture_exception
-
Rails.logger.error "LeadSquared API error processing contact: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @account).capture_exception
-
Rails.logger.error "Error processing contact in LeadSquared: #{e.message}"
-
end
-
-
def create_conversation_activity(conversation:, activity_type:, activity_code_key:, metadata_key:, activity_note:)
-
lead_id = get_lead_id(conversation.contact)
-
return if lead_id.blank?
-
-
activity_code = get_activity_code(activity_code_key)
-
activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note)
-
return if activity_id.blank?
-
-
metadata = {}
-
metadata[metadata_key] = activity_id
-
store_conversation_metadata(conversation, metadata)
-
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
-
ChatwootExceptionTracker.new(e, account: @account).capture_exception
-
Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @account).capture_exception
-
Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}"
-
end
-
-
def get_activity_code(key)
-
activity_code = @hook.settings[key]
-
raise StandardError, "LeadSquared #{key} activity code not found for hook ##{@hook.id}." if activity_code.blank?
-
-
activity_code
-
end
-
-
def get_lead_id(contact)
-
contact.reload # reload to ensure all the attributes are up-to-date
-
-
unless identifiable_contact?(contact)
-
Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}")
-
nil
-
end
-
-
lead_id = @lead_finder.find_or_create(contact)
-
return nil if lead_id.blank?
-
-
store_external_id(contact, lead_id) unless get_external_id(contact)
-
-
lead_id
-
end
-
end
-
class Crm::Leadsquared::SetupService
-
def initialize(hook)
-
@hook = hook
-
credentials = @hook.settings
-
-
@access_key = credentials['access_key']
-
@secret_key = credentials['secret_key']
-
-
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
-
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
-
end
-
-
def setup
-
setup_endpoint
-
setup_activity
-
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
-
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
-
Rails.logger.error "LeadSquared API error in setup: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
-
Rails.logger.error "Error during LeadSquared setup: #{e.message}"
-
end
-
-
def setup_endpoint
-
response = @client.get('Authentication.svc/UserByAccessKey.Get')
-
endpoint_host = response['LSQCommonServiceURLs']['api']
-
app_host = response['LSQCommonServiceURLs']['app']
-
-
endpoint_url = "https://#{endpoint_host}/v2/"
-
app_url = "https://#{app_host}/"
-
-
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
-
-
# replace the clients
-
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
-
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, endpoint_url)
-
end
-
-
private
-
-
def setup_activity
-
existing_types = @activity_client.fetch_activity_types
-
return if existing_types.blank?
-
-
activity_codes = setup_activity_types(existing_types)
-
return if activity_codes.blank?
-
-
update_hook_settings(activity_codes)
-
-
activity_codes
-
end
-
-
def setup_activity_types(existing_types)
-
activity_codes = {}
-
-
activity_types.each do |activity_type|
-
activity_id = find_or_create_activity_type(activity_type, existing_types)
-
-
if activity_id.present?
-
activity_codes[activity_type[:setting_key]] = activity_id.to_i
-
else
-
Rails.logger.error "Failed to find or create activity type: #{activity_type[:name]}"
-
end
-
end
-
-
activity_codes
-
end
-
-
def find_or_create_activity_type(activity_type, existing_types)
-
existing = existing_types.find { |t| t['ActivityEventName'] == activity_type[:name] }
-
-
if existing
-
existing['ActivityEvent'].to_i
-
else
-
@activity_client.create_activity_type(
-
name: activity_type[:name],
-
score: activity_type[:score],
-
direction: activity_type[:direction]
-
)
-
end
-
end
-
-
def update_hook_settings(params)
-
@hook.settings = @hook.settings.merge(params)
-
@hook.save!
-
end
-
-
def activity_types
-
[
-
{
-
name: "#{brand_name} Conversation Started",
-
score: @hook.settings['conversation_activity_score'].to_i || 0,
-
direction: 0,
-
setting_key: 'conversation_activity_code'
-
},
-
{
-
name: "#{brand_name} Conversation Transcript",
-
score: @hook.settings['transcript_activity_score'].to_i || 0,
-
direction: 0,
-
setting_key: 'transcript_activity_code'
-
}
-
].freeze
-
end
-
-
def brand_name
-
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'].presence || 'Chatwoot'
-
end
-
end
-
class DataImport::ContactManager
-
def initialize(account)
-
@account = account
-
end
-
-
def build_contact(params)
-
contact = find_or_initialize_contact(params)
-
update_contact_attributes(params, contact)
-
contact
-
end
-
-
def find_or_initialize_contact(params)
-
contact = find_existing_contact(params)
-
contact_params = params.slice(:email, :identifier, :phone_number)
-
contact_params[:phone_number] = format_phone_number(contact_params[:phone_number]) if contact_params[:phone_number].present?
-
contact ||= @account.contacts.new(contact_params)
-
contact
-
end
-
-
def find_existing_contact(params)
-
contact = find_contact_by_identifier(params)
-
contact ||= find_contact_by_email(params)
-
contact ||= find_contact_by_phone_number(params)
-
-
update_contact_with_merged_attributes(params, contact) if contact.present? && contact.valid?
-
contact
-
end
-
-
def find_contact_by_identifier(params)
-
return unless params[:identifier]
-
-
@account.contacts.find_by(identifier: params[:identifier])
-
end
-
-
def find_contact_by_email(params)
-
return unless params[:email]
-
-
@account.contacts.from_email(params[:email])
-
end
-
-
def find_contact_by_phone_number(params)
-
return unless params[:phone_number]
-
-
@account.contacts.find_by(phone_number: format_phone_number(params[:phone_number]))
-
end
-
-
def format_phone_number(phone_number)
-
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
-
end
-
-
def update_contact_with_merged_attributes(params, contact)
-
contact.identifier = params[:identifier] if params[:identifier].present?
-
contact.email = params[:email] if params[:email].present?
-
contact.phone_number = format_phone_number(params[:phone_number]) if params[:phone_number].present?
-
update_contact_attributes(params, contact)
-
contact.save
-
end
-
-
private
-
-
def update_contact_attributes(params, contact)
-
contact.name = params[:name] if params[:name].present?
-
contact.additional_attributes ||= {}
-
contact.additional_attributes[:company] = params[:company] if params[:company].present?
-
contact.additional_attributes[:city] = params[:city] if params[:city].present?
-
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
-
end
-
end
-
# Code is heavily inspired by panaromic gem
-
# https://github.com/andreapavoni/panoramic
-
# We will try to find layouts and content from database
-
# layout will be rendered with erb and other content in html format
-
# Further processing in liquid is implemented in mailers
-
-
# NOTE: rails resolver looks for templates in cache first
-
# which we don't want to happen here
-
# so we are overriding find_all method in action view resolver
-
# If anything breaks - look into rails : actionview/lib/action_view/template/resolver.rb
-
-
class ::EmailTemplates::DbResolverService < ActionView::Resolver
-
require 'singleton'
-
include Singleton
-
-
# Instantiate Resolver by passing a model.
-
def self.using(model, options = {})
-
class_variable_set(:@@model, model)
-
class_variable_set(:@@resolver_options, options)
-
instance
-
end
-
-
# Since rails picks up files from cache. lets override the method
-
# Normalizes the arguments and passes it on to find_templates.
-
# rubocop:disable Metrics/ParameterLists
-
def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = [])
-
locals = locals.map(&:to_s).sort!.freeze
-
_find_all(name, prefix, partial, details, key, locals)
-
end
-
# rubocop:enable Metrics/ParameterLists
-
-
# the function has to accept(name, prefix, partial, _details, _locals = [])
-
# _details contain local info which we can leverage in future
-
# cause of codeclimate issue with 4 args, relying on (*args)
-
def find_templates(name, prefix, partial, *_args)
-
@template_name = name
-
@template_type = prefix.include?('layout') ? 'layout' : 'content'
-
@db_template = find_db_template
-
-
return [] if @db_template.blank?
-
-
path = build_path(prefix)
-
handler = ActionView::Template.registered_template_handler(:liquid)
-
-
template_details = {
-
locals: [],
-
format: Mime['html'].to_sym,
-
virtual_path: virtual_path(path, partial)
-
}
-
-
[ActionView::Template.new(@db_template.body, "DB Template - #{@db_template.id}", handler, **template_details)]
-
end
-
-
private
-
-
def find_db_template
-
find_account_template || find_installation_template
-
end
-
-
def find_account_template
-
return unless Current.account
-
-
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
-
end
-
-
def find_installation_template
-
@@model.find_by(name: @template_name, template_type: @template_type, account: nil)
-
end
-
-
# Build path with eventual prefix
-
def build_path(prefix)
-
prefix.present? ? "#{prefix}/#{@template_name}" : @template_name
-
end
-
-
# returns a path depending if its a partial or template
-
# params path: path/to/file.ext partial: true/false
-
# the function appends _to make the file name _file.ext if partial: true
-
def virtual_path(path, partial)
-
return path unless partial
-
-
if (index = path.rindex('/'))
-
path.insert(index + 1, '_')
-
else
-
"_#{path}"
-
end
-
end
-
end
-
class Facebook::SendOnFacebookService < Base::SendOnChannelService
-
private
-
-
def channel_class
-
Channel::FacebookPage
-
end
-
-
def perform_reply
-
send_message_to_facebook fb_text_message_params if message.content.present?
-
-
if message.attachments.present?
-
message.attachments.each do |attachment|
-
send_message_to_facebook fb_attachment_message_params(attachment)
-
end
-
end
-
rescue Facebook::Messenger::FacebookError => e
-
# TODO : handle specific errors or else page will get disconnected
-
handle_facebook_error(e)
-
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
-
end
-
-
def send_message_to_facebook(delivery_params)
-
parsed_result = deliver_message(delivery_params)
-
return if parsed_result.nil?
-
-
if parsed_result['error'].present?
-
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_result)).perform
-
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{parsed_result}"
-
end
-
-
message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present?
-
end
-
-
def deliver_message(delivery_params)
-
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
-
JSON.parse(result)
-
rescue JSON::ParserError
-
Messages::StatusUpdateService.new(message, 'failed', 'Facebook was unable to process this request').perform
-
Rails.logger.error "Facebook::SendOnFacebookService: Error parsing JSON response from Facebook : Page - #{channel.page_id} : #{result}"
-
nil
-
rescue Net::OpenTimeout
-
Messages::StatusUpdateService.new(message, 'failed', 'Request timed out, please try again later').perform
-
Rails.logger.error "Facebook::SendOnFacebookService: Timeout error sending message to Facebook : Page - #{channel.page_id}"
-
nil
-
end
-
-
def fb_text_message_params
-
{
-
recipient: { id: contact.get_source_id(inbox.id) },
-
message: { text: message.content },
-
messaging_type: 'MESSAGE_TAG',
-
tag: 'ACCOUNT_UPDATE'
-
}
-
end
-
-
def external_error(response)
-
# https://developers.facebook.com/docs/graph-api/guides/error-handling/
-
error_message = response['error']['message']
-
error_code = response['error']['code']
-
-
"#{error_code} - #{error_message}"
-
end
-
-
def fb_attachment_message_params(attachment)
-
{
-
recipient: { id: contact.get_source_id(inbox.id) },
-
message: {
-
attachment: {
-
type: attachment_type(attachment),
-
payload: {
-
url: attachment.download_url
-
}
-
}
-
},
-
messaging_type: 'MESSAGE_TAG',
-
tag: 'ACCOUNT_UPDATE'
-
}
-
end
-
-
def attachment_type(attachment)
-
return attachment.file_type if %w[image audio video file].include? attachment.file_type
-
-
'file'
-
end
-
-
def sent_first_outgoing_message_after_24_hours?
-
# we can send max 1 message after 24 hour window
-
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
-
end
-
-
def handle_facebook_error(exception)
-
# Refer: https://github.com/jgorset/facebook-messenger/blob/64fe1f5cef4c1e3fca295b205037f64dfebdbcab/lib/facebook/messenger/error.rb
-
return unless exception.to_s.include?('The session has been invalidated') || exception.to_s.include?('Error validating access token')
-
-
channel.authorization_error!
-
end
-
end
-
require 'json'
-
-
class FilterService
-
include Filters::FilterHelper
-
include CustomExceptions::CustomFilter
-
-
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
-
ATTRIBUTE_TYPES = {
-
date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean'
-
}.with_indifferent_access
-
-
def initialize(params, user)
-
@params = params
-
@user = user
-
file = File.read('./lib/filters/filter_keys.yml')
-
@filters = YAML.safe_load(file)
-
@query_string = ''
-
@filter_values = {}
-
end
-
-
def perform; end
-
-
def filter_operation(query_hash, current_index)
-
case query_hash[:filter_operator]
-
when 'equal_to', 'not_equal_to'
-
@filter_values["value_#{current_index}"] = filter_values(query_hash)
-
equals_to_filter_string(query_hash[:filter_operator], current_index)
-
when 'contains', 'does_not_contain'
-
@filter_values["value_#{current_index}"] = values_for_ilike(query_hash)
-
ilike_filter_string(query_hash[:filter_operator], current_index)
-
when 'is_present'
-
@filter_values["value_#{current_index}"] = 'IS NOT NULL'
-
when 'is_not_present'
-
@filter_values["value_#{current_index}"] = 'IS NULL'
-
when 'is_greater_than', 'is_less_than'
-
@filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
-
when 'days_before'
-
@filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
-
else
-
@filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
-
"= :value_#{current_index}"
-
end
-
end
-
-
def filter_values(query_hash)
-
attribute_key = query_hash['attribute_key']
-
values = query_hash['values']
-
-
return conversation_status_values(values) if attribute_key == 'status'
-
return conversation_priority_values(values) if attribute_key == 'priority'
-
return message_type_values(values) if attribute_key == 'message_type'
-
return downcase_array_values(values) if attribute_key == 'content'
-
-
case_insensitive_values(query_hash)
-
end
-
-
def downcase_array_values(values)
-
values.map(&:downcase)
-
end
-
-
def case_insensitive_values(query_hash)
-
if @custom_attribute_type.present? && query_hash['values'][0].is_a?(String)
-
string_filter_values(query_hash)
-
else
-
query_hash['values']
-
end
-
end
-
-
def values_for_ilike(query_hash)
-
if query_hash['values'].is_a?(Array)
-
query_hash['values']
-
.map { |item| "%#{item.strip}%" }
-
else
-
["%#{query_hash['values'].strip}%"]
-
end
-
end
-
-
def string_filter_values(query_hash)
-
return query_hash['values'][0].downcase if query_hash['values'].is_a?(Array)
-
-
query_hash['values'].downcase
-
end
-
-
def lt_gt_filter_values(query_hash)
-
attribute_key = query_hash[:attribute_key]
-
attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
-
attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
-
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
-
value = query_hash['values'][0]
-
operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
-
"#{operator} '#{value}'::#{attribute_data_type}"
-
end
-
-
def days_before_filter_values(query_hash)
-
date = Time.zone.today - query_hash['values'][0].to_i.days
-
query_hash['values'] = [date.strftime]
-
query_hash['filter_operator'] = 'is_less_than'
-
lt_gt_filter_values(query_hash)
-
end
-
-
def set_count_for_all_conversations
-
[
-
@conversations.assigned_to(@user).count,
-
@conversations.unassigned.count,
-
@conversations.count
-
]
-
end
-
-
def tag_filter_query(query_hash, current_index)
-
model_name = filter_config[:entity]
-
table_name = filter_config[:table_name]
-
query_operator = query_hash[:query_operator]
-
@filter_values["value_#{current_index}"] = filter_values(query_hash)
-
-
tag_model_relation_query =
-
"SELECT * FROM taggings WHERE taggings.taggable_id = #{table_name}.id AND taggings.taggable_type = '#{model_name}'"
-
tag_query =
-
"AND taggings.tag_id IN (SELECT tags.id FROM tags WHERE tags.name IN (:value_#{current_index}))"
-
-
case query_hash[:filter_operator]
-
when 'equal_to'
-
"EXISTS (#{tag_model_relation_query} #{tag_query}) #{query_operator}"
-
when 'not_equal_to'
-
"NOT EXISTS (#{tag_model_relation_query} #{tag_query}) #{query_operator}"
-
when 'is_present'
-
"EXISTS (#{tag_model_relation_query}) #{query_operator}"
-
when 'is_not_present'
-
"NOT EXISTS (#{tag_model_relation_query}) #{query_operator}"
-
end
-
end
-
-
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
-
@attribute_key = query_hash[:attribute_key]
-
@custom_attribute_type = custom_attribute_type
-
attribute_data_type
-
return '' if @custom_attribute.blank?
-
-
build_custom_attr_query(query_hash, current_index)
-
end
-
-
private
-
-
def attribute_model
-
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
-
end
-
-
def attribute_data_type
-
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
-
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
-
end
-
-
def build_custom_attr_query(query_hash, current_index)
-
filter_operator_value = filter_operation(query_hash, current_index)
-
query_operator = query_hash[:query_operator]
-
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
-
-
query = if attribute_data_type == 'text'
-
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
-
else
-
"(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
-
end
-
-
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
-
end
-
-
def custom_attribute(attribute_key, account, custom_attribute_type)
-
current_account = account || Current.account
-
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
-
@custom_attribute = current_account.custom_attribute_definitions.where(
-
attribute_model: attribute_model
-
).find_by(attribute_key: attribute_key)
-
end
-
-
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
-
return '' unless query_hash[:filter_operator] == 'not_equal_to'
-
-
" OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
-
end
-
-
def equals_to_filter_string(filter_operator, current_index)
-
return "IN (:value_#{current_index})" if filter_operator == 'equal_to'
-
-
"NOT IN (:value_#{current_index})"
-
end
-
-
def ilike_filter_string(filter_operator, current_index)
-
return "ILIKE ANY (ARRAY[:value_#{current_index}])" if %w[contains].include?(filter_operator)
-
-
"NOT ILIKE ALL (ARRAY[:value_#{current_index}])"
-
end
-
-
def like_filter_string(filter_operator, current_index)
-
return "LIKE :value_#{current_index}" if %w[contains starts_with].include?(filter_operator)
-
-
"NOT LIKE :value_#{current_index}"
-
end
-
-
def query_builder(model_filters)
-
@params[:payload].each_with_index do |query_hash, current_index|
-
@query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}"
-
end
-
base_relation.where(@query_string, @filter_values.with_indifferent_access)
-
end
-
-
def validate_query_operator
-
@params[:payload].each do |query_hash|
-
validate_single_condition(query_hash)
-
end
-
end
-
end
-
1
require 'rubygems/package'
-
-
1
class Geocoder::SetupService
-
1
def perform
-
1
return if File.exist?(GeocoderConfiguration::LOOK_UP_DB)
-
-
1
ip_lookup_api_key = ENV.fetch('IP_LOOKUP_API_KEY', nil)
-
1
if ip_lookup_api_key.blank?
-
1
log_info('IP_LOOKUP_API_KEY empty. Skipping geoip database setup')
-
1
return
-
end
-
-
log_info('Fetch GeoLite2-City database')
-
fetch_and_extract_database(ip_lookup_api_key)
-
end
-
-
1
private
-
-
1
def fetch_and_extract_database(api_key)
-
base_url = ENV.fetch('IP_LOOKUP_BASE_URL', 'https://download.maxmind.com/app/geoip_download')
-
source_file = Down.download("#{base_url}?edition_id=GeoLite2-City&suffix=tar.gz&license_key=#{api_key}")
-
-
extract_tar_file(source_file)
-
log_info('Fetch complete')
-
rescue StandardError => e
-
log_error(e.message)
-
end
-
-
1
def extract_tar_file(source_file)
-
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open(source_file))
-
tar_extract.rewind
-
-
tar_extract.each do |entry|
-
next unless entry.full_name.include?('GeoLite2-City.mmdb') && entry.file?
-
-
File.open GeocoderConfiguration::LOOK_UP_DB, 'wb' do |f|
-
f.print entry.read
-
end
-
end
-
end
-
-
1
def log_info(message)
-
1
Rails.logger.info "[rake ip_lookup:setup] #{message}"
-
end
-
-
1
def log_error(message)
-
Rails.logger.error "[rake ip_lookup:setup] #{message}"
-
end
-
end
-
# Refer: https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
-
class Google::RefreshOauthTokenService < BaseRefreshOauthTokenService
-
private
-
-
# Builds the OAuth strategy for Microsoft Graph
-
def build_oauth_strategy
-
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
-
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
-
-
OmniAuth::Strategies::GoogleOauth2.new(nil, app_id, app_secret)
-
end
-
end
-
require 'net/imap'
-
-
class Imap::BaseFetchEmailService
-
pattr_initialize [:channel!, :interval]
-
-
def fetch_emails
-
# Override this method
-
end
-
-
def perform
-
inbound_emails = fetch_emails
-
terminate_imap_connection
-
-
inbound_emails
-
end
-
-
private
-
-
def authentication_type
-
# Override this method
-
end
-
-
def imap_password
-
# Override this method
-
end
-
-
def imap_client
-
@imap_client ||= build_imap_client
-
end
-
-
def mail_info_logger(inbound_mail, seq_no)
-
return if Rails.env.test?
-
-
Rails.logger.info("
-
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{seq_no}")
-
end
-
-
def email_already_present?(channel, message_id)
-
channel.inbox.messages.find_by(source_id: message_id).present?
-
end
-
-
def fetch_mail_for_channel
-
message_ids_with_seq = fetch_message_ids_with_sequence
-
message_ids_with_seq.filter_map do |message_id_with_seq|
-
process_message_id(message_id_with_seq)
-
end
-
end
-
-
def process_message_id(message_id_with_seq)
-
seq_no, message_id = message_id_with_seq
-
-
if message_id.blank?
-
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
-
return
-
end
-
-
return if email_already_present?(channel, message_id)
-
-
# Fetch the original mail content using the sequence no
-
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
-
-
if mail_str.blank?
-
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
-
return
-
end
-
-
inbound_mail = build_mail_from_string(mail_str)
-
mail_info_logger(inbound_mail, seq_no)
-
inbound_mail
-
end
-
-
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
-
# You can send batches of message sequence number in `.fetch` method.
-
def fetch_message_ids_with_sequence
-
seq_nums = fetch_available_mail_sequence_numbers
-
-
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
-
-
message_ids_with_seq = []
-
seq_nums.each_slice(10).each do |batch|
-
# Fetch only message-id only without mail body or contents.
-
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
-
-
# .fetch returns an array of Net::IMAP::FetchData or nil
-
# (instead of an empty array) if there is no matching message.
-
# Check
-
if batch_message_ids.blank?
-
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
-
next
-
end
-
-
batch_message_ids.each do |data|
-
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
-
message_ids_with_seq.push([data.seqno, message_id])
-
end
-
end
-
-
message_ids_with_seq
-
end
-
-
# Sends a SEARCH command to search the mailbox for messages that were
-
# created between yesterday (or given date) and today and returns message sequence numbers.
-
# Return <message set>
-
def fetch_available_mail_sequence_numbers
-
imap_client.search(['SINCE', since])
-
end
-
-
def build_imap_client
-
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
-
imap.authenticate(authentication_type, channel.imap_login, imap_password)
-
imap.select('INBOX')
-
imap
-
end
-
-
def terminate_imap_connection
-
imap_client.logout
-
rescue Net::IMAP::Error => e
-
Rails.logger.info "Logout failed for #{channel.email} - #{e.message}."
-
imap_client.disconnect
-
end
-
-
def build_mail_from_string(raw_email_content)
-
Mail.read_from_string(raw_email_content)
-
end
-
-
def since
-
previous_day = Time.zone.today - (interval || 1).to_i
-
previous_day.strftime('%d-%b-%Y')
-
end
-
end
-
class Imap::FetchEmailService < Imap::BaseFetchEmailService
-
def fetch_emails
-
fetch_mail_for_channel
-
end
-
-
private
-
-
def authentication_type
-
'PLAIN'
-
end
-
-
def imap_password
-
channel.imap_password
-
end
-
end
-
class Imap::GoogleFetchEmailService < Imap::BaseFetchEmailService
-
def fetch_emails
-
return if channel.provider_config['access_token'].blank?
-
-
fetch_mail_for_channel
-
end
-
-
private
-
-
def authentication_type
-
'XOAUTH2'
-
end
-
-
def imap_password
-
Google::RefreshOauthTokenService.new(channel: channel).access_token
-
end
-
end
-
class Imap::MicrosoftFetchEmailService < Imap::BaseFetchEmailService
-
def fetch_emails
-
return if channel.provider_config['access_token'].blank?
-
-
fetch_mail_for_channel
-
end
-
-
private
-
-
def authentication_type
-
'XOAUTH2'
-
end
-
-
def imap_password
-
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
-
end
-
end
-
class Instagram::BaseMessageText < Instagram::WebhooksBaseService
-
attr_reader :messaging
-
-
def initialize(messaging, channel)
-
@messaging = messaging
-
super(channel)
-
end
-
-
def perform
-
connected_instagram_id, contact_id = instagram_and_contact_ids
-
inbox_channel(connected_instagram_id)
-
-
return if @inbox.blank?
-
-
if @inbox.channel.reauthorization_required?
-
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
-
return
-
end
-
-
return unsend_message if message_is_deleted?
-
-
ensure_contact(contact_id) if contacts_first_message?(contact_id)
-
-
create_message
-
end
-
-
private
-
-
def instagram_and_contact_ids
-
if agent_message_via_echo?
-
[@messaging[:sender][:id], @messaging[:recipient][:id]]
-
else
-
[@messaging[:recipient][:id], @messaging[:sender][:id]]
-
end
-
end
-
-
def agent_message_via_echo?
-
@messaging[:message][:is_echo].present?
-
end
-
-
def message_is_deleted?
-
@messaging[:message][:is_deleted].present?
-
end
-
-
# if contact was present before find out contact_inbox to create message
-
def contacts_first_message?(ig_scope_id)
-
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
-
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
-
end
-
-
def unsend_message
-
message_to_delete = @inbox.messages.find_by(
-
source_id: @messaging[:message][:mid]
-
)
-
return if message_to_delete.blank?
-
-
message_to_delete.attachments.destroy_all
-
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
-
end
-
-
# Methods to be implemented by subclasses
-
def ensure_contact(contact_id)
-
raise NotImplementedError, "#{self.class} must implement #ensure_contact"
-
end
-
-
def create_message
-
raise NotImplementedError, "#{self.class} must implement #create_message"
-
end
-
end
-
class Instagram::BaseSendService < Base::SendOnChannelService
-
pattr_initialize [:message!]
-
-
private
-
-
delegate :additional_attributes, to: :contact
-
-
def perform_reply
-
send_attachments if message.attachments.present?
-
send_content if message.content.present?
-
rescue StandardError => e
-
handle_error(e)
-
end
-
-
def send_attachments
-
message.attachments.each do |attachment|
-
send_message(attachment_message_params(attachment))
-
end
-
end
-
-
def send_content
-
send_message(message_params)
-
end
-
-
def handle_error(error)
-
ChatwootExceptionTracker.new(error, account: message.account, user: message.sender).capture_exception
-
end
-
-
def message_params
-
params = {
-
recipient: { id: contact.get_source_id(inbox.id) },
-
message: {
-
text: message.content
-
}
-
}
-
-
merge_human_agent_tag(params)
-
end
-
-
def attachment_message_params(attachment)
-
params = {
-
recipient: { id: contact.get_source_id(inbox.id) },
-
message: {
-
attachment: {
-
type: attachment_type(attachment),
-
payload: {
-
url: attachment.download_url
-
}
-
}
-
}
-
}
-
-
merge_human_agent_tag(params)
-
end
-
-
def process_response(response, message_content)
-
parsed_response = response.parsed_response
-
if response.success? && parsed_response['error'].blank?
-
message.update!(source_id: parsed_response['message_id'])
-
parsed_response
-
else
-
external_error = external_error(parsed_response)
-
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
-
Messages::StatusUpdateService.new(message, 'failed', external_error).perform
-
nil
-
end
-
end
-
-
def external_error(response)
-
error_message = response.dig('error', 'message')
-
error_code = response.dig('error', 'code')
-
-
# https://developers.facebook.com/docs/messenger-platform/error-codes
-
# Access token has expired or become invalid. This may be due to a password change,
-
# removal of the connected app from Instagram account settings, or other reasons.
-
channel.authorization_error! if error_code == 190
-
-
"#{error_code} - #{error_message}"
-
end
-
-
def attachment_type(attachment)
-
return attachment.file_type if %w[image audio video file].include? attachment.file_type
-
-
'file'
-
end
-
-
# Methods to be implemented by child classes
-
def send_message(message_content)
-
raise NotImplementedError, 'Subclasses must implement send_message'
-
end
-
-
def merge_human_agent_tag(params)
-
raise NotImplementedError, 'Subclasses must implement merge_human_agent_tag'
-
end
-
end
-
class Instagram::MessageText < Instagram::BaseMessageText
-
attr_reader :messaging
-
-
def ensure_contact(ig_scope_id)
-
result = fetch_instagram_user(ig_scope_id)
-
find_or_create_contact(result) if result.present?
-
end
-
-
def fetch_instagram_user(ig_scope_id)
-
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
-
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
-
-
response = HTTParty.get(url)
-
-
return process_successful_response(response) if response.success?
-
-
handle_error_response(response)
-
{}
-
end
-
-
def process_successful_response(response)
-
result = JSON.parse(response.body).with_indifferent_access
-
{
-
'name' => result['name'],
-
'username' => result['username'],
-
'profile_pic' => result['profile_pic'],
-
'id' => result['id'],
-
'follower_count' => result['follower_count'],
-
'is_user_follow_business' => result['is_user_follow_business'],
-
'is_business_follow_user' => result['is_business_follow_user'],
-
'is_verified_user' => result['is_verified_user']
-
}.with_indifferent_access
-
end
-
-
def handle_error_response(response)
-
parsed_response = response.parsed_response
-
error_message = parsed_response.dig('error', 'message')
-
error_code = parsed_response.dig('error', 'code')
-
-
# https://developers.facebook.com/docs/messenger-platform/error-codes
-
# Access token has expired or become invalid.
-
channel.authorization_error! if error_code == 190
-
-
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
-
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
-
-
ChatwootExceptionTracker.new(parsed_response, account: @inbox.account).capture_exception
-
end
-
-
def base_uri
-
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
-
end
-
-
def create_message
-
return unless @contact_inbox
-
-
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
-
end
-
end
-
class Instagram::Messenger::MessageText < Instagram::BaseMessageText
-
private
-
-
def ensure_contact(ig_scope_id)
-
result = fetch_instagram_user(ig_scope_id)
-
find_or_create_contact(result) if result.present?
-
end
-
-
def fetch_instagram_user(ig_scope_id)
-
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
-
k.get_object(ig_scope_id) || {}
-
rescue Koala::Facebook::AuthenticationError => e
-
handle_authentication_error(e)
-
{}
-
rescue StandardError, Koala::Facebook::ClientError => e
-
handle_client_error(e)
-
{}
-
end
-
-
def handle_authentication_error(error)
-
@inbox.channel.authorization_error!
-
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
-
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
-
end
-
-
def handle_client_error(error)
-
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
-
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
-
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
-
end
-
-
def create_message
-
return unless @contact_inbox
-
-
Messages::Instagram::Messenger::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
-
end
-
end
-
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
-
private
-
-
def channel_class
-
Channel::FacebookPage
-
end
-
-
# Deliver a message with the given payload.
-
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
-
def send_message(message_content)
-
access_token = channel.page_access_token
-
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
-
query = { access_token: access_token }
-
query[:appsecret_proof] = app_secret_proof if app_secret_proof
-
-
response = HTTParty.post(
-
'https://graph.facebook.com/v11.0/me/messages',
-
body: message_content,
-
query: query
-
)
-
-
process_response(response, message_content)
-
end
-
-
def calculate_app_secret_proof(app_secret, access_token)
-
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
-
app_secret, access_token
-
)
-
end
-
-
def merge_human_agent_tag(params)
-
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
-
-
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
-
-
params[:messaging_type] = 'MESSAGE_TAG'
-
params[:tag] = 'HUMAN_AGENT'
-
params
-
end
-
end
-
class Instagram::ReadStatusService
-
pattr_initialize [:params!, :channel!]
-
-
def perform
-
return if channel.blank?
-
-
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
-
end
-
-
def instagram_id
-
params[:recipient][:id]
-
end
-
-
def message
-
return unless params[:read][:mid]
-
-
@message ||= @channel.inbox.messages.find_by(source_id: params[:read][:mid])
-
end
-
end
-
# Service to handle Instagram access token refresh logic
-
# Instagram tokens are valid for 60 days and can be refreshed to extend validity
-
# This service implements the refresh logic per official Instagram API guidelines
-
class Instagram::RefreshOauthTokenService
-
attr_reader :channel
-
-
def initialize(channel:)
-
@channel = channel
-
end
-
-
# Returns a valid access token, refreshing it if necessary and eligible
-
def access_token
-
return unless token_valid?
-
-
# If token is valid and eligible for refresh, attempt to refresh it
-
return channel[:access_token] unless token_eligible_for_refresh?
-
-
attempt_token_refresh
-
end
-
-
private
-
-
# Checks if the current token is still valid (not expired)
-
def token_valid?
-
return false if channel.expires_at.blank?
-
-
# Check if token is still valid
-
Time.current < channel.expires_at
-
end
-
-
# Determines if a token is eligible for refresh based on Instagram's requirements
-
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#refresh-a-long-lived-token
-
-
def token_eligible_for_refresh?
-
# Three conditions must be met:
-
# 1. Token is still valid
-
token_is_valid = Time.current < channel.expires_at
-
-
# 2. Token is at least 24 hours old (based on updated_at)
-
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
-
-
# 3. Token is approaching expiry (within 10 days)
-
approaching_expiry = channel.expires_at < 10.days.from_now
-
-
token_is_valid && token_is_old_enough && approaching_expiry
-
end
-
-
# Makes an API request to refresh the long-lived token
-
# @return [Hash] Response data containing new access_token and expires_in values
-
# @raise [RuntimeError] If API request fails
-
def refresh_long_lived_token
-
endpoint = 'https://graph.instagram.com/refresh_access_token'
-
params = {
-
grant_type: 'ig_refresh_token',
-
access_token: channel[:access_token]
-
}
-
-
response = HTTParty.get(endpoint, query: params, headers: { 'Accept' => 'application/json' })
-
-
unless response.success?
-
Rails.logger.error "Failed to refresh Instagram token: #{response.body}"
-
raise "Failed to refresh Instagram token: #{response.body}"
-
end
-
-
JSON.parse(response.body)
-
end
-
-
def update_channel_tokens(token_data)
-
channel.update!(
-
access_token: token_data['access_token'],
-
expires_at: Time.current + token_data['expires_in'].seconds
-
)
-
end
-
-
# Attempts to refresh the token, returning either the new or existing token
-
def attempt_token_refresh
-
refreshed_token_data = refresh_long_lived_token
-
update_channel_tokens(refreshed_token_data)
-
channel.reload[:access_token]
-
rescue StandardError => e
-
Rails.logger.error("Token refresh failed: #{e.message}")
-
channel[:access_token]
-
end
-
end
-
class Instagram::SendOnInstagramService < Instagram::BaseSendService
-
private
-
-
def channel_class
-
Channel::Instagram
-
end
-
-
# Deliver a message with the given payload.
-
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
-
def send_message(message_content)
-
access_token = channel.access_token
-
query = { access_token: access_token }
-
instagram_id = channel.instagram_id.presence || 'me'
-
-
response = HTTParty.post(
-
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
-
body: message_content,
-
query: query
-
)
-
-
process_response(response, message_content)
-
end
-
-
def merge_human_agent_tag(params)
-
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
-
-
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
-
-
params[:messaging_type] = 'MESSAGE_TAG'
-
params[:tag] = 'HUMAN_AGENT'
-
params
-
end
-
end
-
class Instagram::TestEventService
-
def initialize(messaging)
-
@messaging = messaging
-
end
-
-
def perform
-
Rails.logger.info("Processing Instagram test webhook event, #{@messaging}")
-
-
return false unless test_webhook_event?
-
-
create_test_text
-
end
-
-
private
-
-
def test_webhook_event?
-
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
-
end
-
-
def create_test_text
-
# As of now, we are using the last created instagram channel as the test channel,
-
# since we don't have any other channel for testing purpose at the time of meta approval
-
channel = Channel::Instagram.last
-
-
@inbox = ::Inbox.find_by(channel: channel)
-
return unless @inbox
-
-
@contact = create_test_contact
-
-
@conversation ||= create_test_conversation(conversation_params)
-
-
@message = @conversation.messages.create!(test_message_params)
-
end
-
-
def create_test_contact
-
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
-
unless @contact_inbox
-
@contact_inbox ||= @inbox.channel.create_contact_inbox(
-
'sender_username', 'sender_username'
-
)
-
end
-
-
@contact_inbox.contact
-
end
-
-
def create_test_conversation(conversation_params)
-
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
-
end
-
-
def test_message_params
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: 'incoming',
-
source_id: @messaging[:message][:mid],
-
content: @messaging[:message][:text],
-
sender: @contact
-
}
-
end
-
-
def build_conversation(conversation_params)
-
Conversation.create!(
-
conversation_params.merge(
-
contact_inbox_id: @contact_inbox.id
-
)
-
)
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
additional_attributes: {
-
type: 'instagram_direct_message'
-
}
-
}
-
end
-
end
-
class Instagram::WebhooksBaseService
-
attr_reader :channel
-
-
def initialize(channel)
-
@channel = channel
-
end
-
-
private
-
-
def inbox_channel(_instagram_id)
-
@inbox = ::Inbox.find_by(channel: @channel)
-
end
-
-
def find_or_create_contact(user)
-
@contact_inbox = @inbox.contact_inboxes.where(source_id: user['id']).first
-
@contact = @contact_inbox.contact if @contact_inbox
-
-
update_instagram_profile_link(user) && return if @contact
-
-
@contact_inbox = @inbox.channel.create_contact_inbox(
-
user['id'], user['name']
-
)
-
-
@contact = @contact_inbox.contact
-
update_instagram_profile_link(user)
-
Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_pic']) if user['profile_pic']
-
end
-
-
def update_instagram_profile_link(user)
-
return unless user['username']
-
-
instagram_attributes = build_instagram_attributes(user)
-
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
-
end
-
-
def build_instagram_attributes(user)
-
attributes = {
-
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
-
'social_profiles': { 'instagram': user['username'] },
-
'social_instagram_user_name': user['username']
-
}
-
-
# Add optional attributes if present
-
optional_fields = %w[
-
follower_count
-
is_user_follow_business
-
is_business_follow_user
-
is_verified_user
-
]
-
-
optional_fields.each do |field|
-
next if user[field].nil?
-
-
attributes["social_instagram_#{field}"] = user[field]
-
end
-
-
attributes
-
end
-
end
-
class Internal::RemoveStaleContactInboxesService
-
def perform
-
return unless remove_stale_contact_inbox_job_enabled?
-
-
time_period = 90.days.ago
-
contact_inboxes_to_delete = stale_contact_inboxes(time_period)
-
-
log_stale_contact_inboxes_deletion(contact_inboxes_to_delete, time_period)
-
-
# Since the number of records to delete is very high,
-
# delete_all would be faster than destroy_all since it operates at database level
-
# and avoid loading all the records in memory
-
# Transaction and batching is used to avoid deadlock and memory issues
-
ContactInbox.transaction do
-
contact_inboxes_to_delete
-
.find_in_batches(batch_size: 10_000) do |group|
-
ContactInbox.where(id: group.map(&:id)).delete_all
-
end
-
end
-
end
-
-
private
-
-
def remove_stale_contact_inbox_job_enabled?
-
job_status = ENV.fetch('REMOVE_STALE_CONTACT_INBOX_JOB_STATUS', false)
-
return false unless ActiveModel::Type::Boolean.new.cast(job_status)
-
-
true
-
end
-
-
def stale_contact_inboxes(time_period)
-
ContactInbox.stale_without_conversations(time_period)
-
end
-
-
def log_stale_contact_inboxes_deletion(contact_inboxes, time_period)
-
count = contact_inboxes.count
-
Rails.logger.info "Deleting #{count} stale contact inboxes older than #{time_period}"
-
-
# Log the SQL query without executing it
-
sql_query = contact_inboxes.to_sql
-
Rails.logger.info("SQL Query: #{sql_query}")
-
end
-
end
-
class Internal::RemoveStaleContactsService
-
pattr_initialize [:account!]
-
-
def perform(batch_size = 1000)
-
contacts_to_remove = @account.contacts.stale_without_conversations(30.days.ago)
-
total_deleted = 0
-
-
Rails.logger.info "[Internal::RemoveStaleContactsService] Starting removal of stale contacts for account #{@account.id}"
-
-
contacts_to_remove.find_in_batches(batch_size: batch_size) do |batch|
-
contact_ids = batch.map(&:id)
-
-
ContactInbox.where(contact_id: contact_ids).delete_all
-
Contact.where(id: contact_ids).delete_all
-
total_deleted += batch.size
-
Rails.logger.info "[Internal::RemoveStaleContactsService] Deleted #{batch.size} contacts (#{total_deleted} total) for account #{@account.id}"
-
end
-
end
-
end
-
class Internal::RemoveStaleRedisKeysService
-
pattr_initialize [:account_id!]
-
-
def perform
-
Rails.logger.info "Removing redis stale keys for account #{@account_id}"
-
range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
-
# exclusive minimum score is specified by prefixing (
-
# we are clearing old records because this could clogg up the sorted set
-
::Redis::Alfred.zremrangebyscore(
-
OnlineStatusTracker.presence_key(@account_id, 'Contact'),
-
'-inf',
-
"(#{range_start}"
-
)
-
end
-
end
-
class IpLookupService
-
def perform(ip_address)
-
return if ip_address.blank? || !ip_database_available?
-
-
Geocoder.search(ip_address).first
-
rescue Errno::ETIMEDOUT => e
-
Rails.logger.warn "Exception: IP resolution failed :#{e.message}"
-
end
-
-
private
-
-
def ip_database_available?
-
File.exist?(GeocoderConfiguration::LOOK_UP_DB)
-
end
-
end
-
class Labels::UpdateService
-
pattr_initialize [:new_label_title!, :old_label_title!, :account_id!]
-
-
def perform
-
tagged_conversations.find_in_batches do |conversation_batch|
-
conversation_batch.each do |conversation|
-
conversation.label_list.remove(old_label_title)
-
conversation.label_list.add(new_label_title)
-
conversation.save!
-
end
-
end
-
-
tagged_contacts.find_in_batches do |contact_batch|
-
contact_batch.each do |contact|
-
contact.label_list.remove(old_label_title)
-
contact.label_list.add(new_label_title)
-
contact.save!
-
end
-
end
-
end
-
-
private
-
-
def tagged_conversations
-
account.conversations.tagged_with(old_label_title)
-
end
-
-
def tagged_contacts
-
account.contacts.tagged_with(old_label_title)
-
end
-
-
def account
-
@account ||= Account.find(account_id)
-
end
-
end
-
# ref : https://developers.line.biz/en/docs/messaging-api/receiving-messages/#webhook-event-types
-
# https://developers.line.biz/en/reference/messaging-api/#message-event
-
-
class Line::IncomingMessageService
-
include ::FileTypeHelper
-
pattr_initialize [:inbox!, :params!]
-
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
-
-
def perform
-
# probably test events
-
return if params[:events].blank?
-
-
line_contact_info
-
return if line_contact_info['userId'].blank?
-
-
set_contact
-
set_conversation
-
parse_events
-
end
-
-
private
-
-
def parse_events
-
params[:events].each do |event|
-
next unless message_created? event
-
-
attach_files event['message']
-
@message.save!
-
end
-
end
-
-
def message_created?(event)
-
return unless event_type_message?(event)
-
-
@message = @conversation.messages.build(
-
content: message_content(event),
-
account_id: @inbox.account_id,
-
content_type: message_content_type(event),
-
inbox_id: @inbox.id,
-
message_type: :incoming,
-
sender: @contact,
-
source_id: event['message']['id'].to_s
-
)
-
@message
-
end
-
-
def message_content(event)
-
message_type = event.dig('message', 'type')
-
case message_type
-
when 'text'
-
event.dig('message', 'text')
-
when 'sticker'
-
sticker_id = event.dig('message', 'stickerId')
-
sticker_image_url(sticker_id)
-
end
-
end
-
-
# Currently, Chatwoot doesn't support stickers. As a temporary solution,
-
# we're displaying stickers as images using the sticker ID in markdown format.
-
# This is subject to change in the future. We've chosen not to download and display the sticker as an image because the sticker's information
-
# and images are the property of the creator or legal owner. We aim to avoid storing it on our server without their consent.
-
# If there are any permission or rendering issues, the URL may break, and we'll display the sticker ID as text instead.
-
# Ref: https://developers.line.biz/en/reference/messaging-api/#wh-sticker
-
def sticker_image_url(sticker_id)
-
""
-
end
-
-
def message_content_type(event)
-
return 'sticker' if event['message']['type'] == 'sticker'
-
-
'text'
-
end
-
-
def attach_files(message)
-
return unless message_type_non_text?(message['type'])
-
-
response = inbox.channel.client.get_message_content(message['id'])
-
-
file_name = "media-#{message['id']}.#{response.content_type.split('/')[1]}"
-
temp_file = Tempfile.new(file_name)
-
temp_file.binmode
-
temp_file << response.body
-
temp_file.rewind
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_content_type(response),
-
file: {
-
io: temp_file,
-
filename: file_name,
-
content_type: response.content_type
-
}
-
)
-
end
-
-
def event_type_message?(event)
-
event['type'] == 'message' || event['type'] == 'sticker'
-
end
-
-
def message_type_non_text?(type)
-
[Line::Bot::Event::MessageType::Video, Line::Bot::Event::MessageType::Audio, Line::Bot::Event::MessageType::Image].include?(type)
-
end
-
-
def account
-
@account ||= inbox.account
-
end
-
-
def line_contact_info
-
@line_contact_info ||= JSON.parse(inbox.channel.client.get_profile(params[:events].first['source']['userId']).body)
-
end
-
-
def set_contact
-
contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: line_contact_info['userId'],
-
inbox: inbox,
-
contact_attributes: contact_attributes
-
).perform
-
-
@contact_inbox = contact_inbox
-
@contact = contact_inbox.contact
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id
-
}
-
end
-
-
def set_conversation
-
@conversation = @contact_inbox.conversations.first
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def contact_attributes
-
{
-
name: line_contact_info['displayName'],
-
avatar_url: line_contact_info['pictureUrl'],
-
additional_attributes: additional_attributes
-
}
-
end
-
-
def additional_attributes
-
{
-
social_line_user_id: line_contact_info['userId']
-
}
-
end
-
-
def file_content_type(file_content)
-
file_type(file_content.content_type)
-
end
-
end
-
class Line::SendOnLineService < Base::SendOnChannelService
-
private
-
-
def channel_class
-
Channel::Line
-
end
-
-
def perform_reply
-
response = channel.client.push_message(message.conversation.contact_inbox.source_id, build_payload)
-
-
return if response.blank?
-
-
parsed_json = JSON.parse(response.body)
-
-
if response.code == '200'
-
# If the request is successful, update the message status to delivered
-
Messages::StatusUpdateService.new(message, 'delivered').perform
-
else
-
# If the request is not successful, update the message status to failed and save the external error
-
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_json)).perform
-
end
-
end
-
-
def build_payload
-
if message.content && message.attachments.any?
-
[text_message, *attachments]
-
elsif message.content.nil? && message.attachments.any?
-
attachments
-
else
-
text_message
-
end
-
end
-
-
def attachments
-
message.attachments.map do |attachment|
-
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
-
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
-
-
{
-
type: attachment.file_type,
-
originalContentUrl: attachment.download_url,
-
previewImageUrl: attachment.download_url
-
}
-
end
-
end
-
-
# https://developers.line.biz/en/reference/messaging-api/#text-message
-
def text_message
-
{
-
type: 'text',
-
text: message.content
-
}
-
end
-
-
# https://developers.line.biz/en/reference/messaging-api/#error-responses
-
def external_error(error)
-
# Message containing information about the error. See https://developers.line.biz/en/reference/messaging-api/#error-messages
-
message = error['message']
-
# An array of error details. If the array is empty, this property will not be included in the response.
-
details = error['details']
-
-
return message if details.blank?
-
-
detail_messages = details.map { |detail| "#{detail['property']}: #{detail['message']}" }
-
[message, detail_messages].join(', ')
-
end
-
end
-
class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
-
def format
-
sections = []
-
sections << "Contact ID: ##{@record.id}"
-
sections << 'Contact Attributes:'
-
sections << build_attributes
-
sections << 'Contact Notes:'
-
sections << if @record.notes.any?
-
build_notes
-
else
-
'No notes for this contact'
-
end
-
-
sections.join("\n")
-
end
-
-
private
-
-
def build_notes
-
@record.notes.all.map { |note| " - #{note.content}" }.join("\n")
-
end
-
-
def build_attributes
-
attributes = []
-
attributes << "Name: #{@record.name}"
-
attributes << "Email: #{@record.email}"
-
attributes << "Phone: #{@record.phone_number}"
-
attributes << "Location: #{@record.location}"
-
attributes << "Country Code: #{@record.country_code}"
-
@record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute|
-
attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
-
end
-
attributes.join("\n")
-
end
-
end
-
class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
-
def format
-
sections = []
-
sections << "Conversation ID: ##{@record.display_id}"
-
sections << "Channel: #{@record.inbox.channel.name}"
-
sections << 'Message History:'
-
sections << if @record.messages.any?
-
build_messages
-
else
-
'No messages in this conversation'
-
end
-
-
sections.join("\n")
-
end
-
-
private
-
-
def build_messages
-
return "No messages in this conversation\n" if @record.messages.empty?
-
-
message_text = ''
-
@record.messages.chat.order(created_at: :asc).each do |message|
-
message_text << format_message(message)
-
end
-
message_text
-
end
-
-
def format_message(message)
-
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
-
"#{sender}: #{message.content}\n"
-
end
-
end
-
class LlmFormatter::DefaultLlmFormatter
-
def initialize(record)
-
@record = record
-
end
-
-
def format
-
# override this
-
end
-
end
-
class LlmFormatter::LlmTextFormatterService
-
def initialize(record)
-
@record = record
-
end
-
-
def format
-
formatter_class = find_formatter
-
formatter_class.new(@record).format
-
end
-
-
private
-
-
def find_formatter
-
formatter_name = "LlmFormatter::#{@record.class.name}LlmFormatter"
-
formatter_class = formatter_name.safe_constantize
-
raise FormatterNotFoundError, "No formatter found for #{@record.class.name}" unless formatter_class
-
-
formatter_class
-
end
-
end
-
class Macros::ExecutionService < ActionService
-
def initialize(macro, conversation, user)
-
super(conversation)
-
@macro = macro
-
@account = macro.account
-
@user = user
-
Current.user = user
-
end
-
-
def perform
-
@macro.actions.each do |action|
-
action = action.with_indifferent_access
-
begin
-
send(action[:action_name], action[:action_params])
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: @account).capture_exception
-
end
-
end
-
ensure
-
Current.reset
-
end
-
-
private
-
-
def assign_agent(agent_ids)
-
agent_ids = agent_ids.map { |id| id == 'self' ? @user.id : id }
-
super(agent_ids)
-
end
-
-
def add_private_note(message)
-
return if conversation_a_tweet?
-
-
params = { content: message[0], private: true }
-
-
# Added reload here to ensure conversation us persistent with the latest updates
-
mb = Messages::MessageBuilder.new(@user, @conversation.reload, params)
-
mb.perform
-
end
-
-
def send_message(message)
-
return if conversation_a_tweet?
-
-
params = { content: message[0], private: false }
-
-
# Added reload here to ensure conversation us persistent with the latest updates
-
mb = Messages::MessageBuilder.new(@user, @conversation.reload, params)
-
mb.perform
-
end
-
-
def send_attachment(blob_ids)
-
return if conversation_a_tweet?
-
-
return unless @macro.files.attached?
-
-
blobs = ActiveStorage::Blob.where(id: blob_ids)
-
-
return if blobs.blank?
-
-
params = { content: nil, private: false, attachments: blobs }
-
-
# Added reload here to ensure conversation us persistent with the latest updates
-
mb = Messages::MessageBuilder.new(@user, @conversation.reload, params)
-
mb.perform
-
end
-
-
def send_webhook_event(webhook_url)
-
payload = @conversation.webhook_data.merge(event: 'macro.executed')
-
WebhookJob.perform_later(webhook_url.first, payload)
-
end
-
end
-
1
class MessageTemplates::HookExecutionService
-
1
pattr_initialize [:message!]
-
-
1
def perform
-
6
return if conversation.campaign.present?
-
6
return if conversation.last_incoming_message.blank?
-
-
6
trigger_templates
-
end
-
-
1
private
-
-
1
delegate :inbox, :conversation, to: :message
-
1
delegate :contact, to: :conversation
-
-
1
def trigger_templates
-
6
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
-
6
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
-
6
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
-
6
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
-
end
-
-
1
def should_send_out_of_office_message?
-
# should not send if its a tweet message
-
6
return false if conversation.tweet?
-
# should not send for outbound messages
-
6
return false unless message.incoming?
-
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
-
# ensures better UX by not interrupting active conversations at the end of business hours
-
3
return false if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago])
-
-
3
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
-
end
-
-
1
def first_message_from_contact?
-
6
conversation.messages.outgoing.count.zero? && conversation.messages.template.count.zero?
-
end
-
-
1
def should_send_greeting?
-
# should not send if its a tweet message
-
6
return false if conversation.tweet?
-
-
6
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
-
end
-
-
1
def email_collect_was_sent?
-
conversation.messages.where(content_type: 'input_email').present?
-
end
-
-
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
-
1
def should_send_email_collect?
-
6
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
-
end
-
-
1
def contact_has_email?
-
6
contact.email
-
end
-
-
1
def csat_enabled_conversation?
-
6
return false unless conversation.resolved?
-
# should not sent since the link will be public
-
return false if conversation.tweet?
-
return false unless inbox.csat_survey_enabled?
-
-
true
-
end
-
-
1
def should_send_csat_survey?
-
6
return unless csat_enabled_conversation?
-
-
# only send CSAT once in a conversation
-
return if conversation.messages.where(content_type: :input_csat).present?
-
-
true
-
end
-
end
-
1
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
-
class MessageTemplates::Template::AutoResolve
-
pattr_initialize [:conversation!]
-
-
def perform
-
return if conversation.account.auto_resolve_message.blank?
-
-
ActiveRecord::Base.transaction do
-
conversation.messages.create!(auto_resolve_message_params)
-
end
-
end
-
-
private
-
-
delegate :contact, :account, to: :conversation
-
delegate :inbox, to: :message
-
-
def auto_resolve_message_params
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: :template,
-
content: account.auto_resolve_message
-
}
-
end
-
end
-
class MessageTemplates::Template::CsatSurvey
-
pattr_initialize [:conversation!]
-
-
def perform
-
ActiveRecord::Base.transaction do
-
conversation.messages.create!(csat_survey_message_params)
-
end
-
end
-
-
private
-
-
delegate :contact, :account, to: :conversation
-
delegate :inbox, to: :message
-
-
def csat_survey_message_params
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: :template,
-
content_type: :input_csat,
-
content: I18n.t('conversations.templates.csat_input_message_body')
-
}
-
end
-
end
-
class MessageTemplates::Template::EmailCollect
-
pattr_initialize [:conversation!]
-
-
def perform
-
ActiveRecord::Base.transaction do
-
conversation.messages.create!(ways_to_reach_you_message_params)
-
conversation.messages.create!(email_input_box_template_message_params)
-
end
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
-
true
-
end
-
-
private
-
-
delegate :contact, :account, to: :conversation
-
delegate :inbox, to: :message
-
-
def ways_to_reach_you_message_params
-
content = I18n.t('conversations.templates.ways_to_reach_you_message_body',
-
account_name: account.name)
-
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: :template,
-
content: content
-
}
-
end
-
-
def email_input_box_template_message_params
-
content = I18n.t('conversations.templates.email_input_box_message_body',
-
account_name: account.name)
-
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: :template,
-
content_type: :input_email,
-
content: content
-
}
-
end
-
end
-
class MessageTemplates::Template::Greeting
-
pattr_initialize [:conversation!]
-
-
def perform
-
ActiveRecord::Base.transaction do
-
conversation.messages.create!(greeting_message_params)
-
end
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
-
true
-
end
-
-
private
-
-
delegate :contact, :account, to: :conversation
-
delegate :inbox, to: :message
-
-
def greeting_message_params
-
content = @conversation.inbox&.greeting_message
-
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: :template,
-
content: content
-
}
-
end
-
end
-
class MessageTemplates::Template::OutOfOffice
-
pattr_initialize [:conversation!]
-
-
def perform
-
ActiveRecord::Base.transaction do
-
conversation.messages.create!(out_of_office_message_params)
-
end
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
-
true
-
end
-
-
private
-
-
delegate :contact, :account, to: :conversation
-
delegate :inbox, to: :message
-
-
def out_of_office_message_params
-
content = @conversation.inbox&.out_of_office_message
-
-
{
-
account_id: @conversation.account_id,
-
inbox_id: @conversation.inbox_id,
-
message_type: :template,
-
content: content
-
}
-
end
-
end
-
1
class Messages::InReplyToMessageBuilder
-
1
pattr_initialize [:message!, :in_reply_to!, :in_reply_to_external_id!]
-
-
1
delegate :conversation, to: :message
-
-
1
def perform
-
6
set_in_reply_to_attribute if @in_reply_to.present? || @in_reply_to_external_id.present?
-
end
-
-
1
private
-
-
1
def set_in_reply_to_attribute
-
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
-
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
-
end
-
-
1
def in_reply_to_message
-
return conversation.messages.find_by(id: @in_reply_to) if @in_reply_to.present?
-
-
return conversation.messages.find_by(source_id: @in_reply_to_external_id) if @in_reply_to_external_id
-
-
nil
-
end
-
end
-
class Messages::MentionService
-
pattr_initialize [:message!]
-
-
def perform
-
return unless valid_mention_message?(message)
-
-
validated_mentioned_ids = filter_mentioned_ids_by_inbox
-
return if validated_mentioned_ids.blank?
-
-
Conversations::UserMentionJob.perform_later(validated_mentioned_ids, message.conversation.id, message.account.id)
-
generate_notifications_for_mentions(validated_mentioned_ids)
-
add_mentioned_users_as_participants(validated_mentioned_ids)
-
end
-
-
private
-
-
def valid_mention_message?(message)
-
message.private? && message.content.present? && mentioned_ids.present?
-
end
-
-
def mentioned_ids
-
@mentioned_ids ||= message.content.scan(%r{\(mention://(user|team)/(\d+)/(.+?)\)}).map(&:second).uniq
-
end
-
-
def filter_mentioned_ids_by_inbox
-
inbox = message.inbox
-
valid_mentionable_ids = inbox.account.administrators.map(&:id) + inbox.members.map(&:id)
-
# Intersection of ids
-
mentioned_ids & valid_mentionable_ids.uniq.map(&:to_s)
-
end
-
-
def generate_notifications_for_mentions(validated_mentioned_ids)
-
validated_mentioned_ids.each do |user_id|
-
NotificationBuilder.new(
-
notification_type: 'conversation_mention',
-
user: User.find(user_id),
-
account: message.account,
-
primary_actor: message.conversation,
-
secondary_actor: message
-
).perform
-
end
-
end
-
-
def add_mentioned_users_as_participants(validated_mentioned_ids)
-
validated_mentioned_ids.each do |user_id|
-
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
-
end
-
end
-
end
-
class Messages::NewMessageNotificationService
-
pattr_initialize [:message!]
-
-
def perform
-
return unless message.notifiable?
-
-
notify_conversation_assignee
-
notify_participating_users
-
end
-
-
private
-
-
delegate :conversation, :sender, :account, to: :message
-
-
def notify_conversation_assignee
-
return if conversation.assignee.blank?
-
return if already_notified?(conversation.assignee)
-
return if conversation.assignee == sender
-
-
NotificationBuilder.new(
-
notification_type: 'assigned_conversation_new_message',
-
user: conversation.assignee,
-
account: account,
-
primary_actor: message.conversation,
-
secondary_actor: message
-
).perform
-
end
-
-
def notify_participating_users
-
participating_users = conversation.conversation_participants.map(&:user)
-
participating_users -= [sender] if sender.is_a?(User)
-
-
participating_users.uniq.each do |participant|
-
next if already_notified?(participant)
-
-
NotificationBuilder.new(
-
notification_type: 'participating_conversation_new_message',
-
user: participant,
-
account: account,
-
primary_actor: message.conversation,
-
secondary_actor: message
-
).perform
-
end
-
end
-
-
# The user could already have been notified via a mention or via assignment
-
# So we don't need to notify them again
-
def already_notified?(user)
-
conversation.notifications.exists?(user: user, secondary_actor: message)
-
end
-
end
-
class Messages::StatusUpdateService
-
attr_reader :message, :status, :external_error
-
-
def initialize(message, status, external_error = nil)
-
@message = message
-
@status = status
-
@external_error = external_error
-
end
-
-
def perform
-
return false unless valid_status_transition?
-
-
update_message_status
-
end
-
-
private
-
-
def update_message_status
-
# Update status and set external_error only when failed
-
message.update!(
-
status: status,
-
external_error: (status == 'failed' ? external_error : nil)
-
)
-
end
-
-
def valid_status_transition?
-
return false unless Message.statuses.key?(status)
-
-
# Don't allow changing from 'read' to 'delivered'
-
return false if message.read? && status == 'delivered'
-
-
true
-
end
-
end
-
# Refer: https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
-
class Microsoft::RefreshOauthTokenService < BaseRefreshOauthTokenService
-
private
-
-
# Builds the OAuth strategy for Microsoft Graph
-
def build_oauth_strategy
-
::MicrosoftGraphAuth.new(nil, GlobalConfigService.load('AZURE_APP_ID', ''), GlobalConfigService.load('AZURE_APP_SECRET', ''))
-
end
-
end
-
class Notification::EmailNotificationService
-
pattr_initialize [:notification!]
-
-
def perform
-
# don't send emails if user read the push notification already
-
return if notification.read_at.present?
-
# don't send emails if user is not confirmed
-
return if notification.user.confirmed_at.nil?
-
return unless user_subscribed_to_notification?
-
-
# TODO : Clean up whatever happening over here
-
# Segregate the mailers properly
-
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification
-
.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later
-
end
-
-
private
-
-
def user_subscribed_to_notification?
-
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id)
-
return true if notification_setting.public_send("email_#{notification.notification_type}?")
-
-
false
-
end
-
end
-
class Notification::FcmService
-
SCOPES = ['https://www.googleapis.com/auth/firebase.messaging'].freeze
-
-
def initialize(project_id, credentials)
-
@project_id = project_id
-
@credentials = credentials
-
@token_info = nil
-
end
-
-
def fcm_client
-
FCM.new(current_token, credentials_path, @project_id)
-
end
-
-
private
-
-
def current_token
-
@token_info = generate_token if @token_info.nil? || token_expired?
-
@token_info[:token]
-
end
-
-
def token_expired?
-
Time.zone.now >= @token_info[:expires_at]
-
end
-
-
def generate_token
-
authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
-
json_key_io: credentials_path,
-
scope: SCOPES
-
)
-
token = authorizer.fetch_access_token!
-
{
-
token: token['access_token'],
-
expires_at: Time.zone.now + token['expires_in'].to_i
-
}
-
end
-
-
def credentials_path
-
StringIO.new(@credentials)
-
end
-
end
-
class Notification::PushNotificationService
-
include Rails.application.routes.url_helpers
-
-
pattr_initialize [:notification!]
-
-
def perform
-
return unless user_subscribed_to_notification?
-
-
notification_subscriptions.each do |subscription|
-
send_browser_push(subscription)
-
send_fcm_push(subscription)
-
send_push_via_chatwoot_hub(subscription)
-
end
-
end
-
-
private
-
-
delegate :user, to: :notification
-
delegate :notification_subscriptions, to: :user
-
delegate :notification_settings, to: :user
-
-
def user_subscribed_to_notification?
-
notification_setting = notification_settings.find_by(account_id: notification.account.id)
-
return true if notification_setting.public_send("push_#{notification.notification_type}?")
-
-
false
-
end
-
-
def conversation
-
@conversation ||= notification.conversation
-
end
-
-
def push_message
-
{
-
title: notification.push_message_title,
-
tag: "#{notification.notification_type}_#{conversation.display_id}_#{notification.id}",
-
url: push_url
-
}
-
end
-
-
def push_url
-
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id)
-
end
-
-
def can_send_browser_push?(subscription)
-
VapidService.public_key && subscription.browser_push?
-
end
-
-
def browser_push_payload(subscription)
-
{
-
message: JSON.generate(push_message),
-
endpoint: subscription.subscription_attributes['endpoint'],
-
p256dh: subscription.subscription_attributes['p256dh'],
-
auth: subscription.subscription_attributes['auth'],
-
vapid: {
-
subject: push_url,
-
public_key: VapidService.public_key,
-
private_key: VapidService.private_key
-
},
-
ssl_timeout: 5,
-
open_timeout: 5,
-
read_timeout: 5
-
}
-
end
-
-
def send_browser_push(subscription)
-
return unless can_send_browser_push?(subscription)
-
-
WebPush.payload_send(**browser_push_payload(subscription))
-
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
-
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
-
Rails.logger.info "WebPush subscription expired: #{e.message}"
-
subscription.destroy!
-
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
-
Rails.logger.error "WebPush operation error: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
-
true
-
end
-
-
def send_fcm_push(subscription)
-
return unless firebase_credentials_present?
-
return unless subscription.fcm?
-
-
fcm_service = Notification::FcmService.new(
-
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil), GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
-
)
-
fcm = fcm_service.fcm_client
-
response = fcm.send_v1(fcm_options(subscription))
-
remove_subscription_if_error(subscription, response)
-
end
-
-
def send_push_via_chatwoot_hub(subscription)
-
return if firebase_credentials_present?
-
return unless chatwoot_hub_enabled?
-
return unless subscription.fcm?
-
-
ChatwootHub.send_push(fcm_options(subscription))
-
end
-
-
def firebase_credentials_present?
-
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
-
end
-
-
def chatwoot_hub_enabled?
-
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
-
end
-
-
def remove_subscription_if_error(subscription, response)
-
if JSON.parse(response[:body])['results']&.first&.keys&.include?('error')
-
subscription.destroy!
-
else
-
Rails.logger.info("FCM push sent to #{user.email} with title #{push_message[:title]}")
-
end
-
end
-
-
def fcm_options(subscription)
-
{
-
'token': subscription.subscription_attributes['push_token'],
-
'data': fcm_data,
-
'notification': fcm_notification,
-
'android': fcm_android_options,
-
'apns': fcm_apns_options,
-
'fcm_options': {
-
analytics_label: 'Label'
-
}
-
}
-
end
-
-
def fcm_data
-
{
-
payload: {
-
data: {
-
notification: notification.fcm_push_data
-
}
-
}.to_json
-
}
-
end
-
-
def fcm_notification
-
{
-
title: notification.push_message_title,
-
body: notification.push_message_body
-
}
-
end
-
-
def fcm_android_options
-
{
-
priority: 'high'
-
}
-
end
-
-
def fcm_apns_options
-
{
-
payload: {
-
aps: {
-
sound: 'default',
-
category: Time.zone.now.to_i.to_s
-
}
-
}
-
}
-
end
-
end
-
class SearchService
-
pattr_initialize [:current_user!, :current_account!, :params!, :search_type!]
-
-
def perform
-
case search_type
-
when 'Message'
-
{ messages: filter_messages }
-
when 'Conversation'
-
{ conversations: filter_conversations }
-
when 'Contact'
-
{ contacts: filter_contacts }
-
else
-
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations }
-
end
-
end
-
-
private
-
-
def accessable_inbox_ids
-
@accessable_inbox_ids ||= @current_user.assigned_inboxes.pluck(:id)
-
end
-
-
def search_query
-
@search_query ||= params[:q].to_s.strip
-
end
-
-
def filter_conversations
-
@conversations = current_account.conversations.where(inbox_id: accessable_inbox_ids)
-
.joins('INNER JOIN contacts ON conversations.contact_id = contacts.id')
-
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
-
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
-
.order('conversations.created_at DESC')
-
.page(params[:page])
-
.per(15)
-
end
-
-
def filter_messages
-
@messages = if use_gin_search
-
filter_messages_with_gin
-
else
-
filter_messages_with_like
-
end
-
end
-
-
def filter_messages_with_gin
-
base_query = message_base_query
-
-
if search_query.present?
-
# Use the @@ operator with to_tsquery for better GIN index utilization
-
# Convert search query to tsquery format with prefix matching
-
-
# Use this if we wanna match splitting the words
-
# split_query = search_query.split.map { |term| "#{term} | #{term}:*" }.join(' & ')
-
-
# This will do entire sentence matching using phrase distance operator
-
tsquery = search_query.split.join(' <-> ')
-
-
# Apply the text search using the GIN index
-
base_query.where('content @@ to_tsquery(?)', tsquery)
-
.reorder('created_at DESC')
-
.page(params[:page])
-
.per(15)
-
else
-
base_query.reorder('created_at DESC')
-
.page(params[:page])
-
.per(15)
-
end
-
end
-
-
def filter_messages_with_like
-
message_base_query
-
.where('messages.content ILIKE :search', search: "%#{search_query}%")
-
.reorder('created_at DESC')
-
.page(params[:page])
-
.per(15)
-
end
-
-
def message_base_query
-
current_account.messages.where(inbox_id: accessable_inbox_ids)
-
.where('created_at >= ?', 3.months.ago)
-
end
-
-
def use_gin_search
-
current_account.feature_enabled?('search_with_gin')
-
end
-
-
def filter_contacts
-
@contacts = current_account.contacts.where(
-
"name ILIKE :search OR email ILIKE :search OR phone_number
-
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
-
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
-
end
-
end
-
class Sms::DeliveryStatusService
-
pattr_initialize [:inbox!, :params!]
-
-
def perform
-
return unless supported_status?
-
-
process_status if message.present?
-
end
-
-
private
-
-
def process_status
-
@message.status = status
-
@message.external_error = external_error if error_occurred?
-
@message.save!
-
end
-
-
def supported_status?
-
%w[message-delivered message-failed].include?(params[:type])
-
end
-
-
# Relevant documentation:
-
# https://dev.bandwidth.com/docs/mfa/webhooks/international/message-delivered
-
# https://dev.bandwidth.com/docs/mfa/webhooks/international/message-failed
-
def status
-
type_mapping = {
-
'message-delivered' => 'delivered',
-
'message-failed' => 'failed'
-
}
-
-
type_mapping[params[:type]]
-
end
-
-
def external_error
-
return nil unless error_occurred?
-
-
error_message = params[:description]
-
error_code = params[:errorCode]
-
-
"#{error_code} - #{error_message}"
-
end
-
-
def error_occurred?
-
params[:errorCode] && params[:type] == 'message-failed'
-
end
-
-
def message
-
return unless params[:message][:id]
-
-
@message ||= inbox.messages.find_by(source_id: params[:message][:id])
-
end
-
end
-
class Sms::IncomingMessageService
-
include ::FileTypeHelper
-
-
pattr_initialize [:inbox!, :params!]
-
-
def perform
-
set_contact
-
set_conversation
-
@message = @conversation.messages.create!(
-
content: params[:text],
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
message_type: :incoming,
-
sender: @contact,
-
source_id: params[:id]
-
)
-
attach_files
-
@message.save!
-
end
-
-
private
-
-
def account
-
@account ||= @inbox.account
-
end
-
-
def channel
-
@channel ||= @inbox.channel
-
end
-
-
def phone_number
-
params[:from]
-
end
-
-
def formatted_phone_number
-
TelephoneNumber.parse(phone_number).international_number
-
end
-
-
def set_contact
-
contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: params[:from],
-
inbox: @inbox,
-
contact_attributes: contact_attributes
-
).perform
-
-
@contact_inbox = contact_inbox
-
@contact = contact_inbox.contact
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id
-
}
-
end
-
-
def set_conversation
-
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
-
@conversation = if @inbox.lock_to_single_conversation
-
@contact_inbox.conversations.last
-
else
-
@contact_inbox.conversations.where
-
.not(status: :resolved).last
-
end
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def contact_attributes
-
{
-
name: formatted_phone_number,
-
phone_number: phone_number
-
}
-
end
-
-
def attach_files
-
return if params[:media].blank?
-
-
params[:media].each do |media_url|
-
# we don't need to process this files since chatwoot doesn't support it
-
next if media_url.end_with?('.smil', '.xml')
-
-
attachment_file = Down.download(
-
media_url,
-
http_basic_authentication: [channel.provider_config['api_key'], channel.provider_config['api_secret']]
-
)
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_type(attachment_file.content_type),
-
file: {
-
io: attachment_file,
-
filename: attachment_file.original_filename,
-
content_type: attachment_file.content_type
-
}
-
)
-
end
-
end
-
end
-
class Sms::OneoffSmsCampaignService
-
pattr_initialize [:campaign!]
-
-
def perform
-
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
-
raise 'Completed Campaign' if campaign.completed?
-
-
# marks campaign completed so that other jobs won't pick it up
-
campaign.completed!
-
-
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
-
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
-
process_audience(audience_labels)
-
end
-
-
private
-
-
delegate :inbox, to: :campaign
-
delegate :channel, to: :inbox
-
-
def process_audience(audience_labels)
-
campaign.account.contacts.tagged_with(audience_labels, any: true).each do |contact|
-
next if contact.phone_number.blank?
-
-
send_message(to: contact.phone_number, content: campaign.message)
-
end
-
end
-
-
def send_message(to:, content:)
-
channel.send_text_message(to, content)
-
end
-
end
-
class Sms::SendOnSmsService < Base::SendOnChannelService
-
private
-
-
def channel_class
-
Channel::Sms
-
end
-
-
def perform_reply
-
send_on_sms
-
end
-
-
def send_on_sms
-
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
-
message.update!(source_id: message_id) if message_id.present?
-
end
-
end
-
# Find the various telegram payload samples here: https://core.telegram.org/bots/webhooks#testing-your-bot-with-updates
-
# https://core.telegram.org/bots/api#available-types
-
-
class Telegram::IncomingMessageService
-
include ::FileTypeHelper
-
include ::Telegram::ParamHelpers
-
pattr_initialize [:inbox!, :params!]
-
-
def perform
-
# chatwoot doesn't support group conversations at the moment
-
return unless private_message?
-
-
set_contact
-
update_contact_avatar
-
set_conversation
-
@message = @conversation.messages.build(
-
content: telegram_params_message_content,
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
message_type: :incoming,
-
sender: @contact,
-
content_attributes: telegram_params_content_attributes,
-
source_id: telegram_params_message_id.to_s
-
)
-
-
process_message_attachments if message_params?
-
@message.save!
-
end
-
-
private
-
-
def set_contact
-
contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: telegram_params_from_id,
-
inbox: inbox,
-
contact_attributes: contact_attributes
-
).perform
-
-
@contact_inbox = contact_inbox
-
@contact = contact_inbox.contact
-
end
-
-
def process_message_attachments
-
attach_location
-
attach_files
-
attach_contact
-
end
-
-
def update_contact_avatar
-
return if @contact.avatar.attached?
-
-
avatar_url = inbox.channel.get_telegram_profile_image(telegram_params_from_id)
-
::Avatar::AvatarFromUrlJob.perform_later(@contact, avatar_url) if avatar_url
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: conversation_additional_attributes
-
}
-
end
-
-
def set_conversation
-
@conversation = @contact_inbox.conversations.first
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def contact_attributes
-
{
-
name: "#{telegram_params_first_name} #{telegram_params_last_name}",
-
additional_attributes: additional_attributes
-
}
-
end
-
-
def additional_attributes
-
{
-
# TODO: Remove this once we show the social_telegram_user_name in the UI instead of the username
-
username: telegram_params_username,
-
language_code: telegram_params_language_code,
-
social_telegram_user_id: telegram_params_from_id,
-
social_telegram_user_name: telegram_params_username
-
}
-
end
-
-
def conversation_additional_attributes
-
{
-
chat_id: telegram_params_chat_id
-
}
-
end
-
-
def file_content_type
-
return :image if params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
-
return :audio if params[:message][:voice].present? || params[:message][:audio].present?
-
return :video if params[:message][:video].present?
-
-
file_type(params[:message][:document][:mime_type])
-
end
-
-
def attach_files
-
return unless file
-
-
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
-
if file_download_path.blank?
-
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
-
return
-
end
-
-
attachment_file = Down.download(
-
inbox.channel.get_telegram_file_path(file[:file_id])
-
)
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_content_type,
-
file: {
-
io: attachment_file,
-
filename: attachment_file.original_filename,
-
content_type: attachment_file.content_type
-
}
-
)
-
end
-
-
def attach_location
-
return unless location
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: :location,
-
fallback_title: location_fallback_title,
-
coordinates_lat: location['latitude'],
-
coordinates_long: location['longitude']
-
)
-
end
-
-
def attach_contact
-
return unless contact_card
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: :contact,
-
fallback_title: contact_card['phone_number'].to_s,
-
meta: {
-
first_name: contact_card['first_name'],
-
last_name: contact_card['last_name']
-
}
-
)
-
end
-
-
def file
-
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
-
end
-
-
def location_fallback_title
-
return '' if venue.blank?
-
-
venue[:title] || ''
-
end
-
-
def venue
-
@venue ||= params.dig(:message, :venue).presence
-
end
-
-
def location
-
@location ||= params.dig(:message, :location).presence
-
end
-
-
def contact_card
-
@contact_card ||= params.dig(:message, :contact).presence
-
end
-
-
def visual_media_params
-
params[:message][:photo].presence&.last || params.dig(:message, :sticker, :thumb).presence || params[:message][:video].presence
-
end
-
end
-
module Telegram::ParamHelpers
-
# ensures that message is from a private chat and not a group chat
-
def private_message?
-
return true if callback_query_params?
-
-
params.dig(:message, :chat, :type) == 'private'
-
end
-
-
def telegram_params_content_attributes
-
reply_to = params.dig(:message, :reply_to_message, :message_id)
-
return { 'in_reply_to_external_id' => reply_to } if reply_to
-
-
{}
-
end
-
-
def message_params?
-
params[:message].present?
-
end
-
-
def callback_query_params?
-
params[:callback_query].present?
-
end
-
-
def telegram_params_base_object
-
if callback_query_params?
-
params[:callback_query]
-
else
-
params[:message]
-
end
-
end
-
-
def telegram_params_from_id
-
telegram_params_base_object[:from][:id]
-
end
-
-
def telegram_params_first_name
-
telegram_params_base_object[:from][:first_name]
-
end
-
-
def telegram_params_last_name
-
telegram_params_base_object[:from][:last_name]
-
end
-
-
def telegram_params_username
-
telegram_params_base_object[:from][:username]
-
end
-
-
def telegram_params_language_code
-
telegram_params_base_object[:from][:language_code]
-
end
-
-
def telegram_params_chat_id
-
if callback_query_params?
-
params[:callback_query][:message][:chat][:id]
-
else
-
telegram_params_base_object[:chat][:id]
-
end
-
end
-
-
def telegram_params_message_content
-
if callback_query_params?
-
params[:callback_query][:data]
-
else
-
params[:message][:text].presence || params[:message][:caption]
-
end
-
end
-
-
def telegram_params_message_id
-
if callback_query_params?
-
params[:callback_query][:id]
-
else
-
params[:message][:message_id]
-
end
-
end
-
end
-
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
-
-
# Media attachments like photos, videos can be clubbed together and sent as a media group
-
# Audio can be clubbed together and send as a media group, but can't be mixed with other types
-
# Documents are sent individually
-
-
# We are using `HTTP URL` to send media attachments, telegram will directly download the media from the URL and send it to the user.
-
# But for documents, we need to send the file as a multipart request. as telegram only support pdf and zip for the download from the URL option.
-
-
# ref: `In sendDocument, sending by URL will currently only work for GIF, PDF and ZIP files.`
-
# ref: `https://core.telegram.org/bots/api#senddocument`
-
# ref: `https://core.telegram.org/bots/api#sendmediaGroup
-
-
# The service will terminate if any of the attachment requests fail when the message has multiple attachments
-
# We will create multiple messages in telegram if the message has multiple attachments (if its documents or mixed media).
-
class Telegram::SendAttachmentsService
-
pattr_initialize [:message!]
-
-
def perform
-
attachment_message_id = nil
-
-
group_attachments_by_type.each do |type, attachments|
-
attachment_message_id = process_attachments_by_type(type, attachments)
-
break if attachment_message_id.nil?
-
end
-
-
attachment_message_id
-
end
-
-
private
-
-
def process_attachments_by_type(type, attachments)
-
response = send_attachments(type, attachments)
-
return extract_attachment_message_id(response) if handle_response(response)
-
-
nil
-
end
-
-
def send_attachments(type, attachments)
-
if [:media, :audio].include?(type)
-
media_group_request(channel.chat_id(message), attachments, channel.reply_to_message_id(message))
-
else
-
send_individual_attachments(attachments)
-
end
-
end
-
-
def group_attachments_by_type
-
attachments_by_type = { media: [], audio: [], document: [] }
-
-
message.attachments.each do |attachment|
-
type = attachment_type(attachment[:file_type])
-
attachment_data = { type: type, media: attachment.download_url, attachment: attachment }
-
case type
-
when 'document'
-
attachments_by_type[:document] << attachment_data
-
when 'audio'
-
attachments_by_type[:audio] << attachment_data
-
when 'photo', 'video'
-
attachments_by_type[:media] << attachment_data
-
end
-
end
-
-
attachments_by_type.reject { |_, v| v.empty? }
-
end
-
-
def attachment_type(file_type)
-
{ 'audio' => 'audio', 'image' => 'photo', 'file' => 'document', 'video' => 'video' }[file_type] || 'document'
-
end
-
-
def media_group_request(chat_id, attachments, reply_to_message_id)
-
HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup",
-
body: {
-
chat_id: chat_id,
-
media: attachments.map { |hash| hash.except(:attachment) }.to_json,
-
reply_to_message_id: reply_to_message_id
-
})
-
end
-
-
def send_individual_attachments(attachments)
-
response = nil
-
attachments.map do |attachment|
-
response = document_request(channel.chat_id(message), attachment, channel.reply_to_message_id(message))
-
break unless handle_response(response)
-
end
-
response
-
end
-
-
def document_request(chat_id, attachment, reply_to_message_id)
-
temp_file_path = save_attachment_to_tempfile(attachment[:attachment])
-
response = send_file(chat_id, temp_file_path, reply_to_message_id)
-
File.delete(temp_file_path)
-
response
-
end
-
-
# Telegram picks up the file name from original field name, so we need to save the file with the original name.
-
# Hence not using Tempfile here.
-
def save_attachment_to_tempfile(attachment)
-
raw_data = attachment.file.download
-
temp_dir = Rails.root.join('tmp/uploads')
-
FileUtils.mkdir_p(temp_dir)
-
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
-
File.write(temp_file_path, raw_data, mode: 'wb')
-
temp_file_path
-
end
-
-
def send_file(chat_id, file_path, reply_to_message_id)
-
File.open(file_path, 'rb') do |file|
-
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
-
body: {
-
chat_id: chat_id,
-
document: file,
-
reply_to_message_id: reply_to_message_id
-
},
-
multipart: true)
-
end
-
end
-
-
def handle_response(response)
-
return true if response.success?
-
-
Rails.logger.error "Message Id: #{message.id} - Error sending attachment to telegram: #{response.parsed_response}"
-
channel.process_error(message, response)
-
false
-
end
-
-
def extract_attachment_message_id(response)
-
return unless response.success?
-
-
result = response.parsed_response['result']
-
# response will be an array if the request for media group
-
# response will be a hash if the request for document
-
result.is_a?(Array) ? result.first['message_id'] : result['message_id']
-
end
-
-
def channel
-
@channel ||= message.inbox.channel
-
end
-
end
-
class Telegram::SendOnTelegramService < Base::SendOnChannelService
-
private
-
-
def channel_class
-
Channel::Telegram
-
end
-
-
def perform_reply
-
## send reply to telegram message api
-
# https://core.telegram.org/bots/api#sendmessage
-
message_id = channel.send_message_on_telegram(message)
-
message.update!(source_id: message_id) if message_id.present?
-
end
-
-
def inbox
-
@inbox ||= message.inbox
-
end
-
-
def channel
-
@channel ||= inbox.channel
-
end
-
end
-
# Find the various telegram payload samples here: https://core.telegram.org/bots/webhooks#testing-your-bot-with-updates
-
# https://core.telegram.org/bots/api#available-types
-
-
class Telegram::UpdateMessageService
-
pattr_initialize [:inbox!, :params!]
-
-
def perform
-
find_contact_inbox
-
find_conversation
-
find_message
-
update_message
-
rescue StandardError => e
-
Rails.logger.error "Error while processing telegram message update #{e.message}"
-
end
-
-
private
-
-
def find_contact_inbox
-
@contact_inbox = inbox.contact_inboxes.find_by!(source_id: params[:edited_message][:chat][:id])
-
end
-
-
def find_conversation
-
@conversation = @contact_inbox.conversations.last
-
end
-
-
def find_message
-
@message = @conversation.messages.find_by(source_id: params[:edited_message][:message_id])
-
end
-
-
def update_message
-
edited_message = params[:edited_message]
-
-
if edited_message[:text].present?
-
@message.update!(content: edited_message[:text])
-
elsif edited_message[:caption].present?
-
@message.update!(content: edited_message[:caption])
-
end
-
end
-
end
-
class Twilio::DeliveryStatusService
-
pattr_initialize [:params!]
-
# Reference: https://www.twilio.com/docs/messaging/api/message-resource#message-status-values
-
-
def perform
-
return if twilio_channel.blank?
-
-
return unless supported_status?
-
-
process_statuses if message.present?
-
end
-
-
private
-
-
def process_statuses
-
@message.status = status
-
@message.external_error = external_error if error_occurred?
-
@message.save!
-
end
-
-
def supported_status?
-
%w[sent delivered read failed undelivered].include?(params[:MessageStatus])
-
end
-
-
def status
-
params[:MessageStatus] == 'undelivered' ? 'failed' : params[:MessageStatus]
-
end
-
-
def external_error
-
return nil unless error_occurred?
-
-
error_message = params[:ErrorMessage].presence
-
error_code = params[:ErrorCode]
-
-
if error_message.present?
-
"#{error_code} - #{error_message}"
-
elsif error_code.present?
-
I18n.t('conversations.messages.delivery_status.error_code', error_code: error_code)
-
end
-
end
-
-
def error_occurred?
-
params[:ErrorCode].present? && %w[failed undelivered].include?(params[:MessageStatus])
-
end
-
-
def twilio_channel
-
@twilio_channel ||= if params[:MessagingServiceSid].present?
-
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
-
elsif params[:AccountSid].present? && params[:From].present?
-
::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
-
end
-
end
-
-
def message
-
return unless params[:MessageSid]
-
-
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
-
end
-
end
-
class Twilio::IncomingMessageService
-
include ::FileTypeHelper
-
-
pattr_initialize [:params!]
-
-
def perform
-
return if twilio_channel.blank?
-
-
set_contact
-
set_conversation
-
@message = @conversation.messages.build(
-
content: message_body,
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
message_type: :incoming,
-
sender: @contact,
-
source_id: params[:SmsSid]
-
)
-
attach_files
-
@message.save!
-
end
-
-
private
-
-
def twilio_channel
-
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
-
if params[:AccountSid].present? && params[:To].present?
-
@twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
-
phone_number: params[:To])
-
end
-
@twilio_channel
-
end
-
-
def inbox
-
@inbox ||= twilio_channel.inbox
-
end
-
-
def account
-
@account ||= inbox.account
-
end
-
-
def phone_number
-
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
-
end
-
-
def formatted_phone_number
-
TelephoneNumber.parse(phone_number).international_number
-
end
-
-
def message_body
-
params[:Body]&.delete("\u0000")
-
end
-
-
def set_contact
-
contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: params[:From],
-
inbox: inbox,
-
contact_attributes: contact_attributes
-
).perform
-
-
@contact_inbox = contact_inbox
-
@contact = contact_inbox.contact
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: additional_attributes
-
}
-
end
-
-
def set_conversation
-
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
-
@conversation = if @inbox.lock_to_single_conversation
-
@contact_inbox.conversations.last
-
else
-
@contact_inbox.conversations.where
-
.not(status: :resolved).last
-
end
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def contact_attributes
-
{
-
name: formatted_phone_number,
-
phone_number: phone_number,
-
additional_attributes: additional_attributes
-
}
-
end
-
-
def additional_attributes
-
if twilio_channel.sms?
-
{
-
from_zip_code: params[:FromZip],
-
from_country: params[:FromCountry],
-
from_state: params[:FromState]
-
}
-
else
-
{}
-
end
-
end
-
-
def attach_files
-
return if params[:MediaUrl0].blank?
-
-
attachment_file = download_attachment_file
-
-
return if attachment_file.blank?
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_type(params[:MediaContentType0]),
-
file: {
-
io: attachment_file,
-
filename: attachment_file.original_filename,
-
content_type: attachment_file.content_type
-
}
-
)
-
end
-
-
def download_attachment_file
-
download_with_auth
-
rescue Down::Error, Down::ClientError => e
-
handle_download_attachment_error(e)
-
end
-
-
def download_with_auth
-
Down.download(
-
params[:MediaUrl0],
-
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
-
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
-
)
-
end
-
-
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
-
def handle_download_attachment_error(error)
-
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
-
Down.download(params[:MediaUrl0])
-
rescue StandardError => e
-
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
-
nil
-
end
-
end
-
class Twilio::OneoffSmsCampaignService
-
pattr_initialize [:campaign!]
-
-
def perform
-
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
-
raise 'Completed Campaign' if campaign.completed?
-
-
# marks campaign completed so that other jobs won't pick it up
-
campaign.completed!
-
-
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
-
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
-
process_audience(audience_labels)
-
end
-
-
private
-
-
delegate :inbox, to: :campaign
-
delegate :channel, to: :inbox
-
-
def process_audience(audience_labels)
-
campaign.account.contacts.tagged_with(audience_labels, any: true).each do |contact|
-
next if contact.phone_number.blank?
-
-
channel.send_message(to: contact.phone_number, body: campaign.message)
-
end
-
end
-
end
-
class Twilio::SendOnTwilioService < Base::SendOnChannelService
-
private
-
-
def channel_class
-
Channel::TwilioSms
-
end
-
-
def perform_reply
-
begin
-
twilio_message = channel.send_message(**message_params)
-
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
-
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
-
end
-
message.update!(source_id: twilio_message.sid) if twilio_message
-
end
-
-
def message_params
-
{
-
body: message.content,
-
to: contact_inbox.source_id,
-
media_url: attachments
-
}
-
end
-
-
def attachments
-
message.attachments.map(&:download_url)
-
end
-
-
def inbox
-
@inbox ||= message.inbox
-
end
-
-
def channel
-
@channel ||= inbox.channel
-
end
-
-
def outgoing_message?
-
message.outgoing? || message.template?
-
end
-
end
-
class Twilio::WebhookSetupService
-
include Rails.application.routes.url_helpers
-
-
pattr_initialize [:inbox!]
-
-
def perform
-
if channel.messaging_service_sid?
-
update_messaging_service
-
else
-
update_phone_number
-
end
-
end
-
-
private
-
-
def update_messaging_service
-
twilio_client
-
.messaging.services(channel.messaging_service_sid)
-
.update(
-
inbound_method: 'POST',
-
inbound_request_url: twilio_callback_index_url,
-
use_inbound_webhook_on_number: false
-
)
-
end
-
-
def update_phone_number
-
if phone_numbers.empty?
-
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
-
else
-
twilio_client
-
.incoming_phone_numbers(phonenumber_sid)
-
.update(sms_method: 'POST', sms_url: twilio_callback_index_url)
-
end
-
end
-
-
def phonenumber_sid
-
phone_numbers.first.sid
-
end
-
-
def phone_numbers
-
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
-
end
-
-
def channel
-
@channel ||= inbox.channel
-
end
-
-
def twilio_client
-
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
-
end
-
end
-
class Twitter::DirectMessageParserService < Twitter::WebhooksBaseService
-
pattr_initialize [:payload]
-
-
def perform
-
return if source_app_id == parent_app_id
-
-
set_inbox
-
ensure_contacts
-
set_conversation
-
@message = @conversation.messages.create!(
-
content: message_create_data['message_data']['text'],
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
message_type: outgoing_message? ? :outgoing : :incoming,
-
sender: @contact,
-
source_id: direct_message_data['id']
-
)
-
attach_files
-
end
-
-
private
-
-
def attach_files
-
return if message_create_data['message_data']['attachment'].blank?
-
-
save_media
-
@message
-
end
-
-
def save_media_urls(file)
-
@message.content_attributes[:media_url] = file['media_url']
-
@message.content_attributes[:display_url] = file['display_url']
-
@message.save!
-
end
-
-
def direct_message_events_params
-
payload['direct_message_events']
-
end
-
-
def direct_message_data
-
direct_message_events_params.first
-
end
-
-
def message_create_data
-
direct_message_data['message_create']
-
end
-
-
def source_app_id
-
message_create_data['source_app_id']
-
end
-
-
def parent_app_id
-
ENV.fetch('TWITTER_APP_ID', '')
-
end
-
-
def media
-
message_create_data['message_data']['attachment']['media']
-
end
-
-
def users
-
payload[:users]
-
end
-
-
def ensure_contacts
-
users.each do |key, user|
-
next if key == profile_id
-
-
find_or_create_contact(user)
-
end
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: {
-
type: 'direct_message'
-
}
-
}
-
end
-
-
def set_conversation
-
@conversation = @contact_inbox.conversations.where("additional_attributes ->> 'type' = 'direct_message'").first
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def outgoing_message?
-
message_create_data['sender_id'] == @inbox.channel.profile_id
-
end
-
-
def api_client
-
@api_client ||= begin
-
consumer = OAuth::Consumer.new(ENV.fetch('TWITTER_CONSUMER_KEY', nil), ENV.fetch('TWITTER_CONSUMER_SECRET', nil),
-
{ site: 'https://api.twitter.com' })
-
token = { oauth_token: @inbox.channel.twitter_access_token, oauth_token_secret: @inbox.channel.twitter_access_token_secret }
-
OAuth::AccessToken.from_hash(consumer, token)
-
end
-
end
-
-
def save_media
-
save_media_urls(media)
-
response = api_client.get(media['media_url'], [])
-
-
temp_file = Tempfile.new('twitter_attachment')
-
temp_file.binmode
-
temp_file << response.body
-
temp_file.rewind
-
-
return unless media['type'] == 'photo'
-
-
@message.attachments.new(
-
account_id: @inbox.account_id,
-
file_type: 'image',
-
file: {
-
io: temp_file,
-
filename: 'twitter_attachment',
-
content_type: media['type']
-
}
-
)
-
@message.save!
-
end
-
end
-
class Twitter::SendOnTwitterService < Base::SendOnChannelService
-
pattr_initialize [:message!]
-
-
private
-
-
delegate :additional_attributes, to: :contact
-
-
def channel_class
-
Channel::TwitterProfile
-
end
-
-
def perform_reply
-
conversation_type == 'tweet' ? send_tweet_reply : send_direct_message
-
end
-
-
def twitter_client
-
Twitty::Facade.new do |config|
-
config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil)
-
config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil)
-
config.access_token = channel.twitter_access_token
-
config.access_token_secret = channel.twitter_access_token_secret
-
config.base_url = 'https://api.twitter.com'
-
config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '')
-
end
-
end
-
-
def conversation_type
-
conversation.additional_attributes['type']
-
end
-
-
def screen_name
-
return "@#{reply_to_message.inbox.name}" if reply_to_message.outgoing?
-
-
"@#{reply_to_message.sender&.additional_attributes.try(:[], 'screen_name') || ''}"
-
end
-
-
def send_direct_message
-
twitter_client.send_direct_message(
-
recipient_id: contact_inbox.source_id,
-
message: message.content
-
)
-
end
-
-
def reply_to_message
-
@reply_to_message ||= if message.in_reply_to
-
conversation.messages.find(message.in_reply_to)
-
else
-
conversation.messages.incoming.last
-
end
-
end
-
-
def send_tweet_reply
-
response = twitter_client.send_tweet_reply(
-
reply_to_tweet_id: reply_to_message.source_id,
-
tweet: "#{screen_name} #{message.content}"
-
)
-
if response.status == '200'
-
tweet_data = response.body
-
message.update!(source_id: tweet_data['id_str'])
-
else
-
Rails.logger.error "TWITTER_TWEET_REPLY_ERROR #{response.body}"
-
end
-
end
-
end
-
class Twitter::TweetParserService < Twitter::WebhooksBaseService
-
pattr_initialize [:payload]
-
-
def perform
-
set_inbox
-
-
return if !tweets_enabled? || message_already_exist? || user_has_blocked?
-
-
create_message
-
end
-
-
private
-
-
def message_type
-
user['id'] == profile_id ? :outgoing : :incoming
-
end
-
-
def tweet_text
-
tweet_data['truncated'] ? tweet_data['extended_tweet']['full_text'] : tweet_data['text']
-
end
-
-
def tweet_create_events_params
-
payload['tweet_create_events']
-
end
-
-
def tweet_data
-
tweet_create_events_params.first
-
end
-
-
def user
-
tweet_data['user']
-
end
-
-
def tweet_id
-
tweet_data['id'].to_s
-
end
-
-
def user_has_blocked?
-
payload['user_has_blocked'] == true
-
end
-
-
def tweets_enabled?
-
@inbox.channel.tweets_enabled?
-
end
-
-
def parent_tweet_id
-
tweet_data['in_reply_to_status_id_str'].nil? ? tweet_data['id'].to_s : tweet_data['in_reply_to_status_id_str']
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id,
-
additional_attributes: {
-
type: 'tweet',
-
tweet_id: parent_tweet_id,
-
tweet_source: tweet_data['source']
-
}
-
}
-
end
-
-
def set_conversation
-
tweet_conversations = @contact_inbox.conversations.where("additional_attributes ->> 'tweet_id' = ?", parent_tweet_id)
-
@conversation = tweet_conversations.first
-
return if @conversation
-
-
tweet_message = @inbox.messages.find_by(source_id: parent_tweet_id)
-
@conversation = tweet_message.conversation if tweet_message
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def message_already_exist?
-
@inbox.messages.find_by(source_id: tweet_id)
-
end
-
-
def create_message
-
find_or_create_contact(user)
-
set_conversation
-
@conversation.messages.create!(
-
account_id: @inbox.account_id,
-
sender: @contact,
-
content: tweet_text,
-
inbox_id: @inbox.id,
-
message_type: message_type,
-
source_id: tweet_id
-
)
-
end
-
end
-
class Twitter::WebhookSubscribeService
-
include Rails.application.routes.url_helpers
-
-
pattr_initialize [:inbox_id]
-
-
def perform
-
ensure_webhook
-
unless subscription?
-
subscribe_response = twitter_client.create_subscription
-
raise StandardError, 'Twitter Subscription Failed' unless subscribe_response.status == '204'
-
end
-
-
true
-
end
-
-
private
-
-
delegate :channel, to: :inbox
-
delegate :twitter_client, to: :channel
-
-
def inbox
-
Inbox.find(inbox_id)
-
end
-
-
def twitter_url
-
webhooks_twitter_url(protocol: 'https')
-
end
-
-
def ensure_webhook
-
webhooks = fetch_webhooks
-
return true if webhooks&.first&.try(:[], 'url') == twitter_url
-
-
# twitter supports only one webhook url per environment
-
# so we will delete the existing one if its not chatwoot
-
unregister_webhook(webhooks.first) if webhooks&.first
-
register_webhook
-
end
-
-
def unregister_webhook(webhook)
-
unregister_response = twitter_client.unregister_webhook(id: webhook.try(:[], 'id'))
-
Rails.logger.info "TWITTER_UNREGISTER_WEBHOOK: #{unregister_response.body}"
-
end
-
-
def register_webhook
-
register_response = twitter_client.register_webhook(url: twitter_url)
-
Rails.logger.info "TWITTER_REGISTER_WEBHOOK: #{register_response.body}"
-
end
-
-
def subscription?
-
response = twitter_client.fetch_subscriptions
-
response.status == '204'
-
end
-
-
def fetch_webhooks
-
twitter_client.fetch_webhooks.body
-
end
-
end
-
class Twitter::WebhooksBaseService
-
private
-
-
def profile_id
-
payload[:for_user_id]
-
end
-
-
def additional_contact_attributes(user)
-
{
-
screen_name: user['screen_name'],
-
location: user['location'],
-
url: user['url'],
-
description: user['description'],
-
followers_count: user['followers_count'],
-
friends_count: user['friends_count']
-
}
-
end
-
-
def set_inbox
-
twitter_profile = ::Channel::TwitterProfile.find_by(profile_id: profile_id)
-
@inbox = ::Inbox.find_by!(channel: twitter_profile)
-
end
-
-
def find_or_create_contact(user)
-
@contact_inbox = @inbox.contact_inboxes.where(source_id: user['id']).first
-
@contact = @contact_inbox.contact if @contact_inbox
-
return if @contact
-
-
@contact_inbox = @inbox.channel.create_contact_inbox(
-
user['id'], user['name'], additional_contact_attributes(user)
-
)
-
@contact = @contact_inbox.contact
-
Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url']
-
end
-
end
-
# Mostly modeled after the intial implementation of the service based on 360 Dialog
-
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
-
# https://developers.facebook.com/docs/whatsapp/api/media/
-
class Whatsapp::IncomingMessageBaseService
-
include ::Whatsapp::IncomingMessageServiceHelpers
-
-
pattr_initialize [:inbox!, :params!]
-
-
def perform
-
processed_params
-
-
if processed_params.try(:[], :statuses).present?
-
process_statuses
-
elsif processed_params.try(:[], :messages).present?
-
process_messages
-
end
-
end
-
-
private
-
-
def process_messages
-
# We don't support reactions & ephemeral message now, we need to skip processing the message
-
# if the webhook event is a reaction or an ephermal message or an unsupported message.
-
return if unprocessable_message_type?(message_type)
-
-
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
-
# business manager account. While we have not found the core reason yet, the following line ensure that
-
# there are no duplicate messages created.
-
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
-
-
cache_message_source_id_in_redis
-
set_contact
-
return unless @contact
-
-
set_conversation
-
create_messages
-
clear_message_source_id_from_redis
-
end
-
-
def process_statuses
-
return unless find_message_by_source_id(@processed_params[:statuses].first[:id])
-
-
update_message_with_status(@message, @processed_params[:statuses].first)
-
rescue ArgumentError => e
-
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
-
end
-
-
def update_message_with_status(message, status)
-
message.status = status[:status]
-
if status[:status] == 'failed' && status[:errors].present?
-
error = status[:errors]&.first
-
message.external_error = "#{error[:code]}: #{error[:title]}"
-
end
-
message.save!
-
end
-
-
def create_messages
-
message = @processed_params[:messages].first
-
log_error(message) && return if error_webhook_event?(message)
-
-
process_in_reply_to(message)
-
-
message_type == 'contacts' ? create_contact_messages(message) : create_regular_message(message)
-
end
-
-
def create_contact_messages(message)
-
message['contacts'].each do |contact|
-
create_message(contact)
-
attach_contact(contact)
-
@message.save!
-
end
-
end
-
-
def create_regular_message(message)
-
create_message(message)
-
attach_files
-
attach_location if message_type == 'location'
-
@message.save!
-
end
-
-
def set_contact
-
contact_params = @processed_params[:contacts]&.first
-
return if contact_params.blank?
-
-
waid = processed_waid(contact_params[:wa_id])
-
-
contact_inbox = ::ContactInboxWithContactBuilder.new(
-
source_id: waid,
-
inbox: inbox,
-
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
-
).perform
-
-
@contact_inbox = contact_inbox
-
@contact = contact_inbox.contact
-
end
-
-
def set_conversation
-
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
-
@conversation = if @inbox.lock_to_single_conversation
-
@contact_inbox.conversations.last
-
else
-
@contact_inbox.conversations
-
.where.not(status: :resolved).last
-
end
-
return if @conversation
-
-
@conversation = ::Conversation.create!(conversation_params)
-
end
-
-
def attach_files
-
return if %w[text button interactive location contacts].include?(message_type)
-
-
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
-
@message.content ||= attachment_payload[:caption]
-
-
attachment_file = download_attachment_file(attachment_payload)
-
return if attachment_file.blank?
-
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_content_type(message_type),
-
file: {
-
io: attachment_file,
-
filename: attachment_file.original_filename,
-
content_type: attachment_file.content_type
-
}
-
)
-
end
-
-
def attach_location
-
location = @processed_params[:messages].first['location']
-
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_content_type(message_type),
-
coordinates_lat: location['latitude'],
-
coordinates_long: location['longitude'],
-
fallback_title: location_name,
-
external_url: location['url']
-
)
-
end
-
-
def create_message(message)
-
@message = @conversation.messages.build(
-
content: message_content(message),
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
message_type: :incoming,
-
sender: @contact,
-
source_id: message[:id].to_s,
-
in_reply_to_external_id: @in_reply_to_external_id
-
)
-
end
-
-
def attach_contact(contact)
-
phones = contact[:phones]
-
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
-
-
phones.each do |phone|
-
@message.attachments.new(
-
account_id: @message.account_id,
-
file_type: file_content_type(message_type),
-
fallback_title: phone[:phone].to_s
-
)
-
end
-
end
-
end
-
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
-
# https://developers.facebook.com/docs/whatsapp/api/media/
-
-
class Whatsapp::IncomingMessageService < Whatsapp::IncomingMessageBaseService
-
end
-
module Whatsapp::IncomingMessageServiceHelpers
-
def download_attachment_file(attachment_payload)
-
Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
-
end
-
-
def conversation_params
-
{
-
account_id: @inbox.account_id,
-
inbox_id: @inbox.id,
-
contact_id: @contact.id,
-
contact_inbox_id: @contact_inbox.id
-
}
-
end
-
-
def processed_params
-
@processed_params ||= params
-
end
-
-
def account
-
@account ||= inbox.account
-
end
-
-
def message_type
-
@processed_params[:messages].first[:type]
-
end
-
-
def message_content(message)
-
# TODO: map interactive messages back to button messages in chatwoot
-
message.dig(:text, :body) ||
-
message.dig(:button, :text) ||
-
message.dig(:interactive, :button_reply, :title) ||
-
message.dig(:interactive, :list_reply, :title) ||
-
message.dig(:name, :formatted_name)
-
end
-
-
def file_content_type(file_type)
-
return :image if %w[image sticker].include?(file_type)
-
return :audio if %w[audio voice].include?(file_type)
-
return :video if ['video'].include?(file_type)
-
return :location if ['location'].include?(file_type)
-
return :contact if ['contacts'].include?(file_type)
-
-
:file
-
end
-
-
def unprocessable_message_type?(message_type)
-
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
-
end
-
-
def brazil_phone_number?(phone_number)
-
phone_number.match(/^55/)
-
end
-
-
# ref: https://github.com/chatwoot/chatwoot/issues/5840
-
def normalised_brazil_mobile_number(phone_number)
-
# DDD : Area codes in Brazil are popularly known as "DDD codes" (códigos DDD) or simply "DDD", from the initials of "direct distance dialing"
-
# https://en.wikipedia.org/wiki/Telephone_numbers_in_Brazil
-
ddd = phone_number[2, 2]
-
# Remove country code and DDD to obtain the number
-
number = phone_number[4, phone_number.length - 4]
-
normalised_number = "55#{ddd}#{number}"
-
# insert 9 to convert the number to the new mobile number format
-
normalised_number = "55#{ddd}9#{number}" if normalised_number.length != 13
-
normalised_number
-
end
-
-
def processed_waid(waid)
-
# in case of Brazil, we need to do additional processing
-
# https://github.com/chatwoot/chatwoot/issues/5840
-
if brazil_phone_number?(waid)
-
# check if there is an existing contact inbox with the normalised waid
-
# We will create conversation against it
-
contact_inbox = inbox.contact_inboxes.find_by(source_id: normalised_brazil_mobile_number(waid))
-
-
# if there is no contact inbox with the waid without 9,
-
# We will create contact inboxes and contacts with the number 9 added
-
waid = contact_inbox.source_id if contact_inbox.present?
-
end
-
waid
-
end
-
-
def error_webhook_event?(message)
-
message.key?('errors')
-
end
-
-
def log_error(message)
-
Rails.logger.warn "Whatsapp Error: #{message['errors'][0]['title']} - contact: #{message['from']}"
-
end
-
-
def process_in_reply_to(message)
-
@in_reply_to_external_id = message['context']&.[]('id')
-
end
-
-
def find_message_by_source_id(source_id)
-
return unless source_id
-
-
@message = Message.find_by(source_id: source_id)
-
end
-
-
def message_under_process?
-
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
-
Redis::Alfred.get(key)
-
end
-
-
def cache_message_source_id_in_redis
-
return if @processed_params.try(:[], :messages).blank?
-
-
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
-
::Redis::Alfred.setex(key, true)
-
end
-
-
def clear_message_source_id_from_redis
-
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
-
::Redis::Alfred.delete(key)
-
end
-
end
-
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
-
# https://developers.facebook.com/docs/whatsapp/api/media/
-
-
class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageBaseService
-
private
-
-
def processed_params
-
@processed_params ||= params[:entry].try(:first).try(:[], 'changes').try(:first).try(:[], 'value')
-
end
-
-
def download_attachment_file(attachment_payload)
-
url_response = HTTParty.get(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
-
# This url response will be failure if the access token has expired.
-
inbox.channel.authorization_error! if url_response.unauthorized?
-
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success?
-
end
-
end
-
#######################################
-
# To create a whatsapp provider
-
# - Inherit this as the base class.
-
# - Implement `send_message` method in your child class.
-
# - Implement `send_template_message` method in your child class.
-
# - Implement `sync_templates` method in your child class.
-
# - Implement `validate_provider_config` method in your child class.
-
# - Use Childclass.new(whatsapp_channel: channel).perform.
-
######################################
-
-
class Whatsapp::Providers::BaseService
-
pattr_initialize [:whatsapp_channel!]
-
-
def send_message(_phone_number, _message)
-
raise 'Overwrite this method in child class'
-
end
-
-
def send_template(_phone_number, _template_info)
-
raise 'Overwrite this method in child class'
-
end
-
-
def sync_template
-
raise 'Overwrite this method in child class'
-
end
-
-
def validate_provider_config
-
raise 'Overwrite this method in child class'
-
end
-
-
def error_message
-
raise 'Overwrite this method in child class'
-
end
-
-
def process_response(response)
-
parsed_response = response.parsed_response
-
if response.success? && parsed_response['error'].blank?
-
parsed_response['messages'].first['id']
-
else
-
handle_error(response)
-
nil
-
end
-
end
-
-
def handle_error(response)
-
Rails.logger.error response.body
-
return if @message.blank?
-
-
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
-
error_message = error_message(response)
-
return if error_message.blank?
-
-
@message.external_error = error_message
-
@message.status = :failed
-
@message.save!
-
end
-
-
def create_buttons(items)
-
buttons = []
-
items.each do |item|
-
button = { :type => 'reply', 'reply' => { 'id' => item['value'], 'title' => item['title'] } }
-
buttons << button
-
end
-
buttons
-
end
-
-
def create_rows(items)
-
rows = []
-
items.each do |item|
-
row = { 'id' => item['value'], 'title' => item['title'] }
-
rows << row
-
end
-
rows
-
end
-
-
def create_payload(type, message_content, action)
-
{
-
'type': type,
-
'body': {
-
'text': message_content
-
},
-
'action': action
-
}
-
end
-
-
def create_payload_based_on_items(message)
-
if message.content_attributes['items'].length <= 3
-
create_button_payload(message)
-
else
-
create_list_payload(message)
-
end
-
end
-
-
def create_button_payload(message)
-
buttons = create_buttons(message.content_attributes['items'])
-
json_hash = { 'buttons' => buttons }
-
create_payload('button', message.content, JSON.generate(json_hash))
-
end
-
-
def create_list_payload(message)
-
rows = create_rows(message.content_attributes['items'])
-
section1 = { 'rows' => rows }
-
sections = [section1]
-
json_hash = { :button => 'Choose an item', 'sections' => sections }
-
create_payload('list', message.content, JSON.generate(json_hash))
-
end
-
end
-
class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseService
-
def send_message(phone_number, message)
-
@message = message
-
if message.attachments.present?
-
send_attachment_message(phone_number, message)
-
elsif message.content_type == 'input_select'
-
send_interactive_text_message(phone_number, message)
-
else
-
send_text_message(phone_number, message)
-
end
-
end
-
-
def send_template(phone_number, template_info)
-
response = HTTParty.post(
-
"#{api_base_path}/messages",
-
headers: api_headers,
-
body: {
-
to: phone_number,
-
template: template_body_parameters(template_info),
-
type: 'template'
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
-
def sync_templates
-
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
-
whatsapp_channel.mark_message_templates_updated
-
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
-
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
-
end
-
-
def validate_provider_config?
-
response = HTTParty.post(
-
"#{api_base_path}/configs/webhook",
-
headers: { 'D360-API-KEY': whatsapp_channel.provider_config['api_key'], 'Content-Type': 'application/json' },
-
body: {
-
url: "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{whatsapp_channel.phone_number}"
-
}.to_json
-
)
-
response.success?
-
end
-
-
def api_headers
-
{ 'D360-API-KEY' => whatsapp_channel.provider_config['api_key'], 'Content-Type' => 'application/json' }
-
end
-
-
def media_url(media_id)
-
"#{api_base_path}/media/#{media_id}"
-
end
-
-
private
-
-
def api_base_path
-
# provide the environment variable when testing against sandbox : 'https://waba-sandbox.360dialog.io/v1'
-
ENV.fetch('360DIALOG_BASE_URL', 'https://waba.360dialog.io/v1')
-
end
-
-
def send_text_message(phone_number, message)
-
response = HTTParty.post(
-
"#{api_base_path}/messages",
-
headers: api_headers,
-
body: {
-
to: phone_number,
-
text: { body: message.content },
-
type: 'text'
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
-
def send_attachment_message(phone_number, message)
-
attachment = message.attachments.first
-
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
-
type_content = {
-
'link': attachment.download_url
-
}
-
type_content['caption'] = message.content unless %w[audio sticker].include?(type)
-
type_content['filename'] = attachment.file.filename if type == 'document'
-
-
response = HTTParty.post(
-
"#{api_base_path}/messages",
-
headers: api_headers,
-
body: {
-
'to' => phone_number,
-
'type' => type,
-
type.to_s => type_content
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
-
def error_message(response)
-
# {"meta": {"success": false, "http_code": 400, "developer_message": "errro-message", "360dialog_trace_id": "someid"}}
-
response.parsed_response.dig('meta', 'developer_message')
-
end
-
-
def template_body_parameters(template_info)
-
{
-
name: template_info[:name],
-
namespace: template_info[:namespace],
-
language: {
-
policy: 'deterministic',
-
code: template_info[:lang_code]
-
},
-
components: [{
-
type: 'body',
-
parameters: template_info[:parameters]
-
}]
-
}
-
end
-
-
def send_interactive_text_message(phone_number, message)
-
payload = create_payload_based_on_items(message)
-
-
response = HTTParty.post(
-
"#{api_base_path}/messages",
-
headers: api_headers,
-
body: {
-
to: phone_number,
-
interactive: payload,
-
type: 'interactive'
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
end
-
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
-
def send_message(phone_number, message)
-
@message = message
-
-
if message.attachments.present?
-
send_attachment_message(phone_number, message)
-
elsif message.content_type == 'input_select'
-
send_interactive_text_message(phone_number, message)
-
else
-
send_text_message(phone_number, message)
-
end
-
end
-
-
def send_template(phone_number, template_info)
-
response = HTTParty.post(
-
"#{phone_id_path}/messages",
-
headers: api_headers,
-
body: {
-
messaging_product: 'whatsapp',
-
to: phone_number,
-
template: template_body_parameters(template_info),
-
type: 'template'
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
-
def sync_templates
-
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
-
whatsapp_channel.mark_message_templates_updated
-
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
-
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
-
end
-
-
def fetch_whatsapp_templates(url)
-
response = HTTParty.get(url)
-
return [] unless response.success?
-
-
next_url = next_url(response)
-
-
return response['data'] + fetch_whatsapp_templates(next_url) if next_url.present?
-
-
response['data']
-
end
-
-
def next_url(response)
-
response['paging'] ? response['paging']['next'] : ''
-
end
-
-
def validate_provider_config?
-
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
-
response.success?
-
end
-
-
def api_headers
-
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
-
end
-
-
def media_url(media_id)
-
"#{api_base_path}/v13.0/#{media_id}"
-
end
-
-
def api_base_path
-
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
-
end
-
-
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
-
def phone_id_path
-
"#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
-
end
-
-
def business_account_path
-
"#{api_base_path}/v14.0/#{whatsapp_channel.provider_config['business_account_id']}"
-
end
-
-
def send_text_message(phone_number, message)
-
response = HTTParty.post(
-
"#{phone_id_path}/messages",
-
headers: api_headers,
-
body: {
-
messaging_product: 'whatsapp',
-
context: whatsapp_reply_context(message),
-
to: phone_number,
-
text: { body: message.content },
-
type: 'text'
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
-
def send_attachment_message(phone_number, message)
-
attachment = message.attachments.first
-
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
-
type_content = {
-
'link': attachment.download_url
-
}
-
type_content['caption'] = message.content unless %w[audio sticker].include?(type)
-
type_content['filename'] = attachment.file.filename if type == 'document'
-
response = HTTParty.post(
-
"#{phone_id_path}/messages",
-
headers: api_headers,
-
body: {
-
:messaging_product => 'whatsapp',
-
:context => whatsapp_reply_context(message),
-
'to' => phone_number,
-
'type' => type,
-
type.to_s => type_content
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
-
def error_message(response)
-
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
-
response.parsed_response&.dig('error', 'message')
-
end
-
-
def template_body_parameters(template_info)
-
{
-
name: template_info[:name],
-
language: {
-
policy: 'deterministic',
-
code: template_info[:lang_code]
-
},
-
components: [{
-
type: 'body',
-
parameters: template_info[:parameters]
-
}]
-
}
-
end
-
-
def whatsapp_reply_context(message)
-
reply_to = message.content_attributes[:in_reply_to_external_id]
-
return nil if reply_to.blank?
-
-
{
-
message_id: reply_to
-
}
-
end
-
-
def send_interactive_text_message(phone_number, message)
-
payload = create_payload_based_on_items(message)
-
-
response = HTTParty.post(
-
"#{phone_id_path}/messages",
-
headers: api_headers,
-
body: {
-
messaging_product: 'whatsapp',
-
to: phone_number,
-
interactive: payload,
-
type: 'interactive'
-
}.to_json
-
)
-
-
process_response(response)
-
end
-
end
-
class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
-
private
-
-
def channel_class
-
Channel::Whatsapp
-
end
-
-
def perform_reply
-
should_send_template_message = template_params.present? || !message.conversation.can_reply?
-
if should_send_template_message
-
send_template_message
-
else
-
send_session_message
-
end
-
end
-
-
def send_template_message
-
name, namespace, lang_code, processed_parameters = processable_channel_message_template
-
-
return if name.blank?
-
-
message_id = channel.send_template(message.conversation.contact_inbox.source_id, {
-
name: name,
-
namespace: namespace,
-
lang_code: lang_code,
-
parameters: processed_parameters
-
})
-
message.update!(source_id: message_id) if message_id.present?
-
end
-
-
def processable_channel_message_template
-
if template_params.present?
-
return [
-
template_params['name'],
-
template_params['namespace'],
-
template_params['language'],
-
processed_templates_params(template_params)
-
]
-
end
-
-
# Delete the following logic once the update for template_params is stable
-
# see if we can match the message content to a template
-
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
-
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
-
# Then we use regex to parse the template varibles and convert them into the proper payload
-
channel.message_templates&.each do |template|
-
match_obj = template_match_object(template)
-
next if match_obj.blank?
-
-
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
-
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
-
-
# no need to look up further end the search
-
return [template['name'], template['namespace'], template['language'], processed_parameters]
-
end
-
[nil, nil, nil, nil]
-
end
-
-
def template_match_object(template)
-
body_object = validated_body_object(template)
-
return if body_object.blank?
-
-
template_match_regex = build_template_match_regex(body_object['text'])
-
message.content.match(template_match_regex)
-
end
-
-
def build_template_match_regex(template_text)
-
# Converts the whatsapp template to a comparable regex string to check against the message content
-
# the variables are of the format {{num}} ex:{{1}}
-
-
# transform the template text into a regex string
-
# we need to replace the {{num}} with matchers that can be used to capture the variables
-
template_text = template_text.gsub(/{{\d}}/, '(.*)')
-
# escape if there are regex characters in the template text
-
template_text = Regexp.escape(template_text)
-
# ensuring only the variables remain as capture groups
-
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
-
-
template_match_string = "^#{template_text}$"
-
Regexp.new template_match_string
-
end
-
-
def template(template_params)
-
channel.message_templates.find do |t|
-
t['name'] == template_params['name'] && t['language'] == template_params['language']
-
end
-
end
-
-
def processed_templates_params(template_params)
-
template = template(template_params)
-
return if template.blank?
-
-
parameter_format = template['parameter_format']
-
-
if parameter_format == 'NAMED'
-
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
-
else
-
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
-
end
-
end
-
-
def validated_body_object(template)
-
# we don't care if its not approved template
-
return if template['status'] != 'approved'
-
-
# we only care about text body object in template. if not present we discard the template
-
# we don't support other forms of templates
-
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
-
end
-
-
def send_session_message
-
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
-
message.update!(source_id: message_id) if message_id.present?
-
end
-
-
def template_params
-
message.additional_attributes && message.additional_attributes['template_params']
-
end
-
end
-
class Widget::TokenService
-
pattr_initialize [:payload, :token]
-
-
def generate_token
-
JWT.encode payload, secret_key, 'HS256'
-
end
-
-
def decode_token
-
JWT.decode(
-
token, secret_key, true, algorithm: 'HS256'
-
).first.symbolize_keys
-
rescue StandardError
-
{}
-
end
-
-
private
-
-
def secret_key
-
Rails.application.secret_key_base
-
end
-
end
-
# TODO: lets move this to active job, since thats what we use over all
-
1
class ConversationReplyEmailWorker
-
1
include Sidekiq::Worker
-
1
sidekiq_options queue: :mailers
-
-
1
def perform(conversation_id, last_queued_id)
-
@conversation = Conversation.find(conversation_id)
-
-
# send the email
-
if @conversation.messages.incoming&.last&.content_type == 'incoming_email'
-
ConversationReplyMailer.with(account: @conversation.account).reply_without_summary(@conversation, last_queued_id).deliver_later
-
else
-
ConversationReplyMailer.with(account: @conversation.account).reply_with_summary(@conversation, last_queued_id).deliver_later
-
end
-
-
# delete the redis set from the first new message on the conversation
-
Redis::Alfred.delete(conversation_mail_key)
-
end
-
-
1
private
-
-
1
def email_inbox?
-
@conversation.inbox&.inbox_type == 'Email'
-
end
-
-
1
def conversation_mail_key
-
format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: @conversation.id)
-
end
-
end
-
class EmailReplyWorker
-
include Sidekiq::Worker
-
sidekiq_options queue: :mailers, retry: 3
-
-
def perform(message_id)
-
message = Message.find(message_id)
-
-
return unless message.email_notifiable_message?
-
-
# send the email
-
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
-
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
-
end
-
end
-
1
module Enterprise::AsyncDispatcher
-
1
def listeners
-
1
super + [
-
CaptainListener.instance
-
]
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: captain_assistants
-
#
-
# id :bigint not null, primary key
-
# config :jsonb not null
-
# description :string
-
# name :string not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# account_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_captain_assistants_on_account_id (account_id)
-
#
-
1
class Captain::Assistant < ApplicationRecord
-
1
self.table_name = 'captain_assistants'
-
-
1
belongs_to :account
-
1
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
-
1
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
-
1
has_many :captain_inboxes,
-
class_name: 'CaptainInbox',
-
foreign_key: :captain_assistant_id,
-
dependent: :destroy_async
-
1
has_many :inboxes,
-
through: :captain_inboxes
-
-
1
validates :name, presence: true
-
1
validates :description, presence: true
-
1
validates :account_id, presence: true
-
-
1
scope :ordered, -> { order(created_at: :desc) }
-
-
1
scope :for_account, ->(account_id) { where(account_id: account_id) }
-
end
-
# == Schema Information
-
#
-
# Table name: captain_inboxes
-
#
-
# id :bigint not null, primary key
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# captain_assistant_id :bigint not null
-
# inbox_id :bigint not null
-
#
-
# Indexes
-
#
-
# index_captain_inboxes_on_captain_assistant_id (captain_assistant_id)
-
# index_captain_inboxes_on_captain_assistant_id_and_inbox_id (captain_assistant_id,inbox_id) UNIQUE
-
# index_captain_inboxes_on_inbox_id (inbox_id)
-
#
-
1
class CaptainInbox < ApplicationRecord
-
1
belongs_to :captain_assistant, class_name: 'Captain::Assistant'
-
1
belongs_to :inbox
-
-
1
validates :inbox_id, uniqueness: true
-
end
-
1
module Enterprise::Account
-
# TODO: Remove this when we upgrade administrate gem to the latest version
-
# this is a temporary method since current administrate doesn't support virtual attributes
-
1
def manually_managed_features; end
-
-
1
def mark_for_deletion(reason = 'manual_deletion')
-
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
-
-
# Send notification to admin users if the account was successfully marked for deletion
-
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
-
-
result
-
end
-
-
1
def unmark_for_deletion
-
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
-
end
-
end
-
1
module Enterprise::Account::PlanUsageAndLimits
-
1
CAPTAIN_RESPONSES = 'captain_responses'.freeze
-
1
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
-
1
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
-
1
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
-
-
1
def usage_limits
-
{
-
agents: agent_limits.to_i,
-
inboxes: get_limits(:inboxes).to_i,
-
captain: {
-
documents: get_captain_limits(:documents),
-
responses: get_captain_limits(:responses)
-
}
-
}
-
end
-
-
1
def increment_response_usage
-
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
-
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
-
save
-
end
-
-
1
def reset_response_usage
-
custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0
-
save
-
end
-
-
1
def update_document_usage
-
# this will ensure that the document count is always accurate
-
custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count
-
save
-
end
-
-
1
def subscribed_features
-
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
-
return [] if plan_features.blank?
-
-
plan_features[plan_name]
-
end
-
-
1
def captain_monthly_limit
-
default_limits = default_captain_limits
-
-
{
-
documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'],
-
responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses']
-
}.with_indifferent_access
-
end
-
-
1
private
-
-
1
def get_captain_limits(type)
-
total_count = captain_monthly_limit[type.to_s].to_i
-
-
consumed = if type == :documents
-
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
-
else
-
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
-
end
-
-
consumed = 0 if consumed.negative?
-
-
{
-
total_count: total_count,
-
current_available: (total_count - consumed).clamp(0, total_count),
-
consumed: consumed
-
}
-
end
-
-
1
def default_captain_limits
-
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
-
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
-
plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value
-
-
# If there are no limits configured, we allow max usage
-
return max_limits if plan_quota.blank?
-
-
# if there is plan_quota configred, but plan_name is not present, we return zero limits
-
return zero_limits if plan_name.blank?
-
-
begin
-
# Now we parse the plan_quota and return the limits for the plan name
-
# but if there's no plan_name present in the plan_quota, we return zero limits
-
plan_quota = JSON.parse(plan_quota) if plan_quota.present?
-
plan_quota[plan_name.downcase] || zero_limits
-
rescue StandardError
-
# if there's any error in parsing the plan_quota, we return max limits
-
# this is to ensure that we don't block the user from using the product
-
max_limits
-
end
-
end
-
-
1
def plan_name
-
custom_attributes['plan_name']
-
end
-
-
1
def agent_limits
-
subscribed_quantity = custom_attributes['subscribed_quantity']
-
subscribed_quantity || get_limits(:agents)
-
end
-
-
1
def get_limits(limit_name)
-
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
-
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
-
-
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
-
-
ChatwootApp.max_limit
-
end
-
-
1
def validate_limit_keys
-
6
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
-
6
self[:limits] = {} if self[:limits].blank?
-
-
limit_schema = {
-
6
'type' => 'object',
-
'properties' => {
-
'inboxes' => { 'type': 'number' },
-
'agents' => { 'type': 'number' },
-
'captain_responses' => { 'type': 'number' },
-
'captain_documents' => { 'type': 'number' }
-
},
-
'required' => [],
-
'additionalProperties' => false
-
}
-
-
6
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits])
-
end
-
end
-
1
module Enterprise::AccountUser
-
1
def permissions
-
custom_role.present? ? (custom_role.permissions + ['custom_role']) : super
-
end
-
end
-
1
module Enterprise::ApplicationRecord
-
1
def droppables
-
super + %w[SlaPolicy]
-
end
-
end
-
1
module Enterprise::Audit::Account
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
audited except: :updated_at, on: [:update]
-
1
has_associated_audits
-
end
-
end
-
1
module Enterprise::Audit::AccountUser
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
audited only: [
-
:availability,
-
:role,
-
:account_id,
-
:inviter_id,
-
:user_id
-
], on: [:create, :update], associated_with: :account
-
end
-
end
-
1
module Enterprise::Audit::Inbox
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
audited associated_with: :account, on: [:create, :update]
-
end
-
end
-
1
module Enterprise::Audit::InboxMember
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
after_commit :create_audit_log_entry_on_create, on: :create
-
1
after_commit :create_audit_log_entry_on_delete, on: :destroy
-
end
-
-
1
private
-
-
1
def create_audit_log_entry_on_create
-
create_audit_log_entry('create')
-
end
-
-
1
def create_audit_log_entry_on_delete
-
create_audit_log_entry('destroy')
-
end
-
-
1
def create_audit_log_entry(action)
-
return if inbox.blank?
-
-
Enterprise::AuditLog.create(
-
auditable_id: id,
-
auditable_type: 'InboxMember',
-
action: action,
-
associated_id: inbox&.account_id,
-
audited_changes: attributes.except('updated_at', 'created_at'),
-
associated_type: 'Account'
-
)
-
end
-
end
-
1
module Enterprise::Audit::Team
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
audited associated_with: :account
-
end
-
end
-
1
module Enterprise::Audit::User
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
# required only for sign_in and sign_out events, which we are logging manually
-
# hence the proc that always returns false
-
1
audited only: [
-
:availability,
-
:display_name,
-
:email,
-
:name
-
3
], unless: proc { |_u| true }
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: audits
-
#
-
# id :bigint not null, primary key
-
# action :string
-
# associated_type :string
-
# auditable_type :string
-
# audited_changes :jsonb
-
# comment :string
-
# remote_address :string
-
# request_uuid :string
-
# user_type :string
-
# username :string
-
# version :integer default(0)
-
# created_at :datetime
-
# associated_id :bigint
-
# auditable_id :bigint
-
# user_id :bigint
-
#
-
# Indexes
-
#
-
# associated_index (associated_type,associated_id)
-
# auditable_index (auditable_type,auditable_id,version)
-
# index_audits_on_created_at (created_at)
-
# index_audits_on_request_uuid (request_uuid)
-
# user_index (user_id,user_type)
-
#
-
1
class Enterprise::AuditLog < Audited::Audit
-
1
after_save :log_additional_information
-
-
1
private
-
-
1
def log_additional_information
-
# rubocop:disable Rails/SkipsModelValidations
-
12
if auditable_type == 'Account' && auditable_id.present?
-
update_columns(associated_type: auditable_type, associated_id: auditable_id, username: user&.email)
-
else
-
12
update_columns(username: user&.email)
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
end
-
1
module Enterprise::Channelable
-
1
extend ActiveSupport::Concern
-
-
# Active support concern has `included` which changes the order of the method lookup chain
-
# https://stackoverflow.com/q/40061982/3824876
-
# manually prepend the instance methods to combat this
-
1
included do
-
1
prepend InstanceMethods
-
end
-
-
1
module InstanceMethods
-
1
def create_audit_log_entry
-
9
account = self.account
-
9
associated_type = 'Account'
-
-
9
return if inbox.nil?
-
-
9
auditable_id = inbox.id
-
9
auditable_type = 'Inbox'
-
9
audited_changes = saved_changes.except('updated_at')
-
-
9
return if audited_changes.blank?
-
-
# skip audit log creation if the only change is whatsapp channel template update
-
return if messaging_template_updates?(audited_changes)
-
-
Enterprise::AuditLog.create(
-
auditable_id: auditable_id,
-
auditable_type: auditable_type,
-
action: 'update',
-
associated_id: account.id,
-
associated_type: associated_type,
-
audited_changes: audited_changes
-
)
-
end
-
-
1
def messaging_template_updates?(changes)
-
# if there is more than one key, return false
-
return false unless changes.keys.length == 1
-
-
# if the only key is message_templates_last_updated, return true
-
changes.key?('message_templates_last_updated')
-
end
-
end
-
end
-
1
module Enterprise::Concerns::Account
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
has_many :sla_policies, dependent: :destroy_async
-
1
has_many :applied_slas, dependent: :destroy_async
-
1
has_many :custom_roles, dependent: :destroy_async
-
-
1
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
-
1
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
-
1
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
-
end
-
end
-
1
module Enterprise::Concerns::AccountUser
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
belongs_to :custom_role, optional: true
-
end
-
end
-
1
module Enterprise::Concerns::Conversation
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
belongs_to :sla_policy, optional: true
-
1
has_one :applied_sla, dependent: :destroy_async
-
1
has_many :sla_events, dependent: :destroy_async
-
1
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
-
7
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
-
7
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
-
end
-
-
1
private
-
-
1
def validate_sla_policy
-
# TODO: remove these validations once we figure out how to deal with these cases
-
if sla_policy_id.nil? && changes[:sla_policy_id].first.present?
-
errors.add(:sla_policy, 'cannot remove sla policy from conversation')
-
return
-
end
-
-
if changes[:sla_policy_id].first.present?
-
errors.add(:sla_policy, 'conversation already has a different sla')
-
return
-
end
-
-
errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id
-
end
-
-
# handling inside a transaction to ensure applied sla record is also created
-
1
def ensure_applied_sla_is_created
-
ActiveRecord::Base.transaction do
-
yield
-
create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank?
-
end
-
rescue ActiveRecord::RecordInvalid
-
raise ActiveRecord::Rollback
-
end
-
end
-
1
module Enterprise::Concerns::Inbox
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
has_one :captain_inbox, dependent: :destroy, class_name: 'CaptainInbox'
-
1
has_one :captain_assistant,
-
through: :captain_inbox,
-
class_name: 'Captain::Assistant'
-
end
-
end
-
1
module Enterprise::Concerns::User
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
1
before_validation :ensure_installation_pricing_plan_quantity, on: :create
-
-
1
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
-
end
-
-
1
def ensure_installation_pricing_plan_quantity
-
3
return unless ChatwootHub.pricing_plan == 'premium'
-
-
errors.add(:base, 'User limit reached. Please purchase more licenses from super admin') if User.count >= ChatwootHub.pricing_plan_quantity
-
end
-
end
-
1
module Enterprise::Conversation
-
1
def list_of_keys
-
6
super + %w[sla_policy_id]
-
end
-
end
-
1
module Enterprise::Inbox
-
1
def member_ids_with_assignment_capacity
-
3
max_assignment_limit = auto_assignment_config['max_assignment_limit']
-
3
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
-
3
super - overloaded_agent_ids
-
end
-
-
1
def active_bot?
-
3
super || captain_active?
-
end
-
-
1
def captain_active?
-
3
captain_assistant.present? && more_responses?
-
end
-
-
1
private
-
-
1
def more_responses?
-
account.usage_limits[:captain][:responses][:current_available].positive?
-
end
-
-
1
def get_agent_ids_over_assignment_limit(limit)
-
conversations.open.select(:assignee_id).group(:assignee_id).having("count(*) >= #{limit.to_i}").filter_map(&:assignee_id)
-
end
-
-
1
def ensure_valid_max_assignment_limit
-
9
return if auto_assignment_config['max_assignment_limit'].blank?
-
return if auto_assignment_config['max_assignment_limit'].to_i.positive?
-
-
errors.add(:auto_assignment_config, 'max_assignment_limit must be greater than 0')
-
end
-
end
-
1
module Enterprise::Conversations::EventDataPresenter
-
1
def push_data
-
12
if account.feature_enabled?('sla')
-
super.merge(
-
applied_sla: applied_sla&.push_event_data,
-
sla_events: sla_events.map(&:push_event_data),
-
sla_policy_id: sla_policy_id
-
)
-
else
-
12
super
-
end
-
end
-
end
-
1
module Enterprise::MessageTemplates::HookExecutionService
-
1
def trigger_templates
-
6
super
-
6
return unless should_process_captain_response?
-
return perform_handoff unless inbox.captain_active?
-
-
Captain::Conversation::ResponseBuilderJob.perform_later(
-
conversation,
-
conversation.inbox.captain_assistant
-
)
-
end
-
-
1
def should_process_captain_response?
-
6
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
-
end
-
-
1
def perform_handoff
-
return unless conversation.pending?
-
-
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
-
conversation.messages.create!(
-
message_type: :outgoing,
-
account_id: conversation.account.id,
-
inbox_id: conversation.inbox.id,
-
content: 'Transferring to another agent for further assistance.'
-
)
-
conversation.bot_handoff!
-
end
-
end
-
1
class CaptainListener < BaseListener
-
1
def conversation_resolved(event)
-
conversation = extract_conversation_and_account(event)[0]
-
assistant = conversation.inbox.captain_assistant
-
return unless conversation.inbox.captain_active?
-
-
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
-
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
-
end
-
end
-
# Code inspired by
-
# http://royvandermeij.com/blog/2011/09/21/create-a-liquid-handler-for-rails-3-dot-1/
-
# https://github.com/chamnap/liquid-rails/blob/master/lib/liquid-rails/template_handler.rb
-
-
1
class ActionView::Template::Handlers::Liquid
-
1
def self.call(template, _source)
-
"ActionView::Template::Handlers::Liquid.new(self).render(#{template.source.inspect}, local_assigns)"
-
end
-
-
1
def initialize(view)
-
@view = view
-
@controller = @view.controller
-
@helper = ActionController::Base.helpers
-
end
-
-
1
def render(template, local_assigns = {})
-
assigns = drops
-
assigns['content_for_layout'] = @view.content_for(:layout) if @view.content_for?(:layout)
-
assigns.merge!(local_assigns)
-
assigns.merge!(locals)
-
-
liquid = Liquid::Template.parse(template)
-
liquid.send(render_method, assigns.stringify_keys, filters: filters, registers: registers.stringify_keys)
-
end
-
-
1
def locals
-
if @controller.respond_to?(:liquid_locals, true)
-
@controller.send(:liquid_locals)
-
else
-
{}
-
end
-
end
-
-
1
def drops
-
droppables = @controller.send(:liquid_droppables) if @controller.respond_to?(:liquid_droppables, true)
-
droppables.update(droppables) { |_, obj| obj.try(:to_drop) || nil }
-
end
-
-
1
def filters
-
if @controller.respond_to?(:liquid_filters, true)
-
@controller.send(:liquid_filters)
-
else
-
[]
-
end
-
end
-
-
1
def registers
-
if @controller.respond_to?(:liquid_registers, true)
-
@controller.send(:liquid_registers)
-
else
-
{}
-
end
-
end
-
-
1
def compilable?
-
false
-
end
-
-
1
def render_method
-
::Rails.env.development? || ::Rails.env.test? ? :render! : :render
-
end
-
end
-
class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
-
def image(node)
-
src, title = extract_img_attributes(node)
-
height = extract_image_height(src)
-
-
render_img_tag(src, title, height)
-
end
-
-
private
-
-
def extract_img_attributes(node)
-
[
-
escape_href(node.url),
-
escape_html(node.title)
-
]
-
end
-
-
def extract_image_height(src)
-
query_params = parse_query_params(src)
-
query_params['cw_image_height']&.first
-
end
-
-
def parse_query_params(url)
-
parsed_url = URI.parse(url)
-
CGI.parse(parsed_url.query || '')
-
rescue URI::InvalidURIError
-
{}
-
end
-
-
def render_img_tag(src, title, height = nil)
-
title_attribute = title.present? ? " title=\"#{title}\"" : ''
-
height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
-
-
plain do
-
# plain ensures that the content is not wrapped in a paragraph tag
-
out("<img src=\"#{src}\"#{title_attribute}#{height_attribute} />")
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'pathname'
-
-
1
module ChatwootApp
-
1
def self.root
-
24
Pathname.new(File.expand_path('..', __dir__))
-
end
-
-
1
def self.max_limit
-
100_000
-
end
-
-
1
def self.enterprise?
-
27
return if ENV.fetch('DISABLE_ENTERPRISE', false)
-
-
27
@enterprise ||= root.join('enterprise').exist?
-
end
-
-
1
def self.chatwoot_cloud?
-
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
-
end
-
-
1
def self.custom?
-
23
@custom ||= root.join('custom').exist?
-
end
-
-
1
def self.help_center_root
-
ENV.fetch('HELPCENTER_URL', nil) || ENV.fetch('FRONTEND_URL', nil)
-
end
-
-
1
def self.extensions
-
23
if custom?
-
%w[enterprise custom]
-
23
elsif enterprise?
-
23
%w[enterprise]
-
else
-
%w[]
-
end
-
end
-
end
-
class ChatwootCaptcha
-
def initialize(client_response)
-
@client_response = client_response
-
@server_key = GlobalConfigService.load('HCAPTCHA_SERVER_KEY', '')
-
end
-
-
def valid?
-
return true if @server_key.blank?
-
return false if @client_response.blank?
-
-
validate_client_response?
-
end
-
-
def validate_client_response?
-
response = HTTParty.post('https://hcaptcha.com/siteverify',
-
body: {
-
response: @client_response,
-
secret: @server_key
-
})
-
-
return unless response.success?
-
-
response.parsed_response['success']
-
end
-
end
-
###############
-
# One library to capture_exception and send to the specific service.
-
# # e as exception, u for user and a for account (user and account are optional)
-
# Usage: ChatwootExceptionTracker(e, user: u, account: a).capture_exception
-
############
-
-
class ChatwootExceptionTracker
-
def initialize(exception, user: nil, account: nil)
-
@exception = exception
-
@user = user
-
@account = account
-
end
-
-
def capture_exception
-
capture_exception_with_sentry if ENV['SENTRY_DSN'].present?
-
Rails.logger.error @exception
-
end
-
-
private
-
-
def capture_exception_with_sentry
-
Sentry.with_scope do |scope|
-
if @account.present?
-
scope.set_context('account', { id: @account.id, name: @account.name })
-
scope.set_tags(account_id: @account.id)
-
end
-
-
scope.set_user(id: @user.id, email: @user.email) if @user.is_a?(User)
-
Sentry.capture_exception(@exception)
-
end
-
end
-
end
-
# TODO: lets use HTTParty instead of RestClient
-
1
class ChatwootHub
-
1
BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com')
-
1
PING_URL = "#{BASE_URL}/ping".freeze
-
1
REGISTRATION_URL = "#{BASE_URL}/instances".freeze
-
1
PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze
-
1
EVENTS_URL = "#{BASE_URL}/events".freeze
-
1
BILLING_URL = "#{BASE_URL}/billing".freeze
-
1
CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze
-
-
1
def self.installation_identifier
-
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
-
identifier ||= InstallationConfig.create!(name: 'INSTALLATION_IDENTIFIER', value: SecureRandom.uuid).value
-
identifier
-
end
-
-
1
def self.billing_url
-
"#{BILLING_URL}?installation_identifier=#{installation_identifier}"
-
end
-
-
1
def self.pricing_plan
-
3
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
-
end
-
-
1
def self.pricing_plan_quantity
-
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0
-
end
-
-
1
def self.support_config
-
{
-
support_website_token: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN')&.value,
-
support_script_url: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_SCRIPT_URL')&.value,
-
support_identifier_hash: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH')&.value
-
}
-
end
-
-
1
def self.instance_config
-
{
-
installation_identifier: installation_identifier,
-
installation_version: Chatwoot.config[:version],
-
installation_host: URI.parse(ENV.fetch('FRONTEND_URL', '')).host,
-
installation_env: ENV.fetch('INSTALLATION_ENV', ''),
-
edition: ENV.fetch('CW_EDITION', '')
-
}
-
end
-
-
1
def self.instance_metrics
-
{
-
accounts_count: fetch_count(Account),
-
users_count: fetch_count(User),
-
inboxes_count: fetch_count(Inbox),
-
conversations_count: fetch_count(Conversation),
-
incoming_messages_count: fetch_count(Message.incoming),
-
outgoing_messages_count: fetch_count(Message.outgoing),
-
additional_information: {}
-
}
-
end
-
-
1
def self.fetch_count(model)
-
model.last&.id || 0
-
end
-
-
1
def self.sync_with_hub
-
begin
-
info = instance_config
-
info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY']
-
response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json })
-
parsed_response = JSON.parse(response)
-
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
-
Rails.logger.error "Exception: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
end
-
parsed_response
-
end
-
-
1
def self.register_instance(company_name, owner_name, owner_email)
-
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
-
RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
-
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
-
Rails.logger.error "Exception: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
end
-
-
1
def self.send_push(fcm_options)
-
info = { fcm_options: fcm_options }
-
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
-
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
-
Rails.logger.error "Exception: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
end
-
-
1
def self.get_captain_settings(account)
-
info = {
-
installation_identifier: installation_identifier,
-
chatwoot_account_id: account.id,
-
account_name: account.name
-
}
-
HTTParty.post(CAPTAIN_ACCOUNTS_URL,
-
body: info.to_json,
-
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' })
-
end
-
-
1
def self.emit_event(event_name, event_data)
-
return if ENV['DISABLE_TELEMETRY']
-
-
info = { event_name: event_name, event_data: event_data }
-
RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
-
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
-
Rails.logger.error "Exception: #{e.message}"
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e).capture_exception
-
end
-
end
-
class ChatwootMarkdownRenderer
-
def initialize(content)
-
@content = content
-
end
-
-
def render_message
-
markdown_renderer = BaseMarkdownRenderer.new
-
doc = CommonMarker.render_doc(@content, :DEFAULT)
-
html = markdown_renderer.render(doc)
-
render_as_html_safe(html)
-
end
-
-
def render_article
-
markdown_renderer = CustomMarkdownRenderer.new
-
doc = CommonMarker.render_doc(@content, :DEFAULT, [:table])
-
html = markdown_renderer.render(doc)
-
-
render_as_html_safe(html)
-
end
-
-
def render_markdown_to_plain_text
-
CommonMarker.render_doc(@content, :DEFAULT).to_plaintext
-
end
-
-
private
-
-
def render_as_html_safe(html)
-
# rubocop:disable Rails/OutputSafety
-
html.html_safe
-
# rubocop:enable Rails/OutputSafety
-
end
-
end
-
class ConfigLoader
-
DEFAULT_OPTIONS = {
-
config_path: nil,
-
reconcile_only_new: true
-
}.freeze
-
-
def process(options = {})
-
options = DEFAULT_OPTIONS.merge(options)
-
# function of the "reconcile_only_new" flag
-
# if true,
-
# it leaves the existing config and feature flags as it is and
-
# creates the missing configs and feature flags with their default values
-
# if false,
-
# then it overwrites existing config and feature flags with default values
-
# also creates the missing configs and feature flags with their default values
-
@reconcile_only_new = options[:reconcile_only_new]
-
-
# setting the config path
-
@config_path = options[:config_path].presence
-
@config_path ||= Rails.root.join('config')
-
-
# general installation configs
-
reconcile_general_config
-
-
# default account based feature configs
-
reconcile_feature_config
-
end
-
-
def general_configs
-
@config_path ||= Rails.root.join('config')
-
@general_configs ||= YAML.safe_load(File.read("#{@config_path}/installation_config.yml")).freeze
-
end
-
-
private
-
-
def account_features
-
@account_features ||= YAML.safe_load(File.read("#{@config_path}/features.yml")).freeze
-
end
-
-
def reconcile_general_config
-
general_configs.each do |config|
-
new_config = config.with_indifferent_access
-
existing_config = InstallationConfig.find_by(name: new_config[:name])
-
save_general_config(existing_config, new_config)
-
end
-
end
-
-
def save_general_config(existing, latest)
-
if existing
-
# save config only if reconcile flag is false and existing configs value does not match default value
-
save_as_new_config(latest) if !@reconcile_only_new && compare_values(existing, latest)
-
else
-
save_as_new_config(latest)
-
end
-
end
-
-
def compare_values(existing, latest)
-
existing.value != latest[:value] ||
-
(!latest[:locked].nil? && existing.locked != latest[:locked])
-
end
-
-
def save_as_new_config(latest)
-
config = InstallationConfig.find_or_initialize_by(name: latest[:name])
-
config.value = latest[:value]
-
config.locked = latest[:locked]
-
config.save!
-
end
-
-
def reconcile_feature_config
-
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
-
-
if config
-
return false if config.value.to_s == account_features.to_s
-
-
compare_and_save_feature(config)
-
else
-
save_as_new_config({ name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS', value: account_features, locked: true })
-
end
-
end
-
-
def compare_and_save_feature(config)
-
features = if @reconcile_only_new
-
# leave the existing feature flag values as it is and add new feature flags with default values
-
(config.value + account_features).uniq { |h| h['name'] }
-
else
-
# update the existing feature flag values with default values and add new feature flags with default values
-
(account_features + config.value).uniq { |h| h['name'] }
-
end
-
config.update({ name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS', value: features, locked: true })
-
end
-
end
-
1
module Current
-
1
thread_mattr_accessor :user
-
1
thread_mattr_accessor :account
-
1
thread_mattr_accessor :account_user
-
1
thread_mattr_accessor :executed_by
-
1
thread_mattr_accessor :contact
-
-
1
def self.reset
-
Current.user = nil
-
Current.account = nil
-
Current.account_user = nil
-
Current.executed_by = nil
-
Current.contact = nil
-
end
-
end
-
# frozen_string_literal: true
-
-
module CustomExceptions::Account
-
class InvalidEmail < CustomExceptions::Base
-
def message
-
if @data[:domain_blocked]
-
I18n.t 'errors.signup.blocked_domain'
-
elsif @data[:disposable]
-
I18n.t 'errors.signup.disposable_email'
-
elsif !@data[:valid]
-
I18n.t 'errors.signup.invalid_email'
-
end
-
end
-
end
-
-
class UserExists < CustomExceptions::Base
-
def message
-
I18n.t('errors.signup.email_already_exists', email: @data[:email])
-
end
-
end
-
-
class InvalidParams < CustomExceptions::Base
-
def message
-
I18n.t 'errors.signup.invalid_params'
-
end
-
end
-
-
class UserErrors < CustomExceptions::Base
-
def message
-
@data[:errors].full_messages.join(',')
-
end
-
end
-
-
class SignupFailed < CustomExceptions::Base
-
def message
-
I18n.t 'errors.signup.failed'
-
end
-
end
-
-
class PlanUpgradeRequired < CustomExceptions::Base
-
def message
-
I18n.t 'errors.plan_upgrade_required.failed'
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class CustomExceptions::Base < StandardError
-
def to_hash
-
{
-
message: message
-
}
-
end
-
-
def http_status
-
403
-
end
-
-
def initialize(data)
-
@data = data
-
super()
-
end
-
end
-
module CustomExceptions::CustomFilter
-
class InvalidAttribute < CustomExceptions::Base
-
def message
-
I18n.t('errors.custom_filters.invalid_attribute', key: @data[:key], allowed_keys: @data[:allowed_keys].join(','))
-
end
-
end
-
-
class InvalidOperator < CustomExceptions::Base
-
def message
-
I18n.t('errors.custom_filters.invalid_operator', attribute_name: @data[:attribute_name], allowed_keys: @data[:allowed_keys].join(','))
-
end
-
end
-
-
class InvalidQueryOperator < CustomExceptions::Base
-
def message
-
I18n.t('errors.custom_filters.invalid_query_operator')
-
end
-
end
-
-
class InvalidValue < CustomExceptions::Base
-
def message
-
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
-
end
-
end
-
end
-
class Dyte
-
BASE_URL = 'https://api.dyte.io/v2'.freeze
-
API_KEY_HEADER = 'Authorization'.freeze
-
PRESET_NAME = 'group_call_host'.freeze
-
-
def initialize(organization_id, api_key)
-
@api_key = Base64.strict_encode64("#{organization_id}:#{api_key}")
-
@organization_id = organization_id
-
-
raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank?
-
end
-
-
def create_a_meeting(title)
-
payload = {
-
'title': title
-
}
-
path = 'meetings'
-
response = post(path, payload)
-
process_response(response)
-
end
-
-
def add_participant_to_meeting(meeting_id, client_id, name, avatar_url)
-
raise ArgumentError, 'Missing information' if meeting_id.blank? || client_id.blank? || name.blank? || avatar_url.blank?
-
-
payload = {
-
'custom_participant_id': client_id.to_s,
-
'name': name,
-
'picture': avatar_url,
-
'preset_name': PRESET_NAME
-
}
-
path = "meetings/#{meeting_id}/participants"
-
response = post(path, payload)
-
process_response(response)
-
end
-
-
private
-
-
def process_response(response)
-
return response.parsed_response['data'].with_indifferent_access if response.success?
-
-
{ error: response.parsed_response, error_code: response.code }
-
end
-
-
def post(path, payload)
-
HTTParty.post(
-
"#{BASE_URL}/#{path}", {
-
headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' },
-
body: payload.to_json
-
}
-
)
-
end
-
end
-
1
class Events::Base
-
1
attr_accessor :data
-
1
attr_reader :name, :timestamp
-
-
1
def initialize(name, timestamp, data)
-
48
@name = name
-
48
@data = data
-
48
@timestamp = timestamp
-
end
-
-
1
def method_name
-
48
name.to_s.tr('.', '_')
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Events::Types
-
### Installation Events ###
-
# account events
-
1
ACCOUNT_CREATED = 'account.created'
-
1
ACCOUNT_CACHE_INVALIDATED = 'account.cache_invalidated'
-
-
#### Account Events ###
-
# campaign events
-
1
CAMPAIGN_TRIGGERED = 'campaign.triggered'
-
-
# channel events
-
1
WEBWIDGET_TRIGGERED = 'webwidget.triggered'
-
-
# conversation events
-
1
CONVERSATION_CREATED = 'conversation.created'
-
1
CONVERSATION_UPDATED = 'conversation.updated'
-
1
CONVERSATION_READ = 'conversation.read'
-
1
CONVERSATION_BOT_HANDOFF = 'conversation.bot_handoff'
-
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
-
1
CONVERSATION_OPENED = 'conversation.opened'
-
1
CONVERSATION_RESOLVED = 'conversation.resolved'
-
-
1
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
-
1
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
-
1
ASSIGNEE_CHANGED = 'assignee.changed'
-
1
TEAM_CHANGED = 'team.changed'
-
1
CONVERSATION_TYPING_ON = 'conversation.typing_on'
-
1
CONVERSATION_TYPING_OFF = 'conversation.typing_off'
-
1
CONVERSATION_MENTIONED = 'conversation.mentioned'
-
-
# message events
-
1
MESSAGE_CREATED = 'message.created'
-
1
FIRST_REPLY_CREATED = 'first.reply.created'
-
1
REPLY_CREATED = 'reply.created'
-
1
MESSAGE_UPDATED = 'message.updated'
-
-
# contact events
-
1
CONTACT_CREATED = 'contact.created'
-
1
CONTACT_UPDATED = 'contact.updated'
-
1
CONTACT_MERGED = 'contact.merged'
-
1
CONTACT_DELETED = 'contact.deleted'
-
-
# contact events
-
1
INBOX_CREATED = 'inbox.created'
-
1
INBOX_UPDATED = 'inbox.updated'
-
-
# notification events
-
1
NOTIFICATION_CREATED = 'notification.created'
-
1
NOTIFICATION_DELETED = 'notification.deleted'
-
1
NOTIFICATION_UPDATED = 'notification.updated'
-
-
# agent events
-
1
AGENT_ADDED = 'agent.added'
-
1
AGENT_REMOVED = 'agent.removed'
-
end
-
require 'net/imap'
-
-
module ExceptionList
-
REST_CLIENT_EXCEPTIONS = [RestClient::NotFound, RestClient::GatewayTimeout, RestClient::BadRequest,
-
RestClient::MethodNotAllowed, RestClient::Forbidden, RestClient::InternalServerError,
-
RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout,
-
RestClient::TemporaryRedirect, RestClient::SSLCertificateNotVerified, RestClient::PaymentRequired,
-
RestClient::BadGateway, RestClient::Unauthorized, RestClient::PayloadTooLarge,
-
RestClient::MovedPermanently, RestClient::ServiceUnavailable, Errno::ECONNREFUSED, SocketError].freeze
-
SMTP_EXCEPTIONS = [
-
Net::SMTPSyntaxError
-
].freeze
-
-
IMAP_EXCEPTIONS = [
-
Errno::ECONNREFUSED, Net::OpenTimeout,
-
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
-
SocketError
-
].freeze
-
end
-
class GlobalConfig
-
VERSION = 'V1'.freeze
-
KEY_PREFIX = 'GLOBAL_CONFIG'.freeze
-
DEFAULT_EXPIRY = 1.day
-
-
class << self
-
def get(*args)
-
config_keys = *args
-
config = {}
-
-
config_keys.each do |config_key|
-
config[config_key] = load_from_cache(config_key)
-
end
-
-
typecast_config(config)
-
config.with_indifferent_access
-
end
-
-
def get_value(arg)
-
load_from_cache(arg)
-
end
-
-
def clear_cache
-
cached_keys = $alfred.with { |conn| conn.keys("#{VERSION}:#{KEY_PREFIX}:*") }
-
(cached_keys || []).each do |cached_key|
-
$alfred.with { |conn| conn.expire(cached_key, 0) }
-
end
-
end
-
-
private
-
-
def typecast_config(config)
-
general_configs = ConfigLoader.new.general_configs
-
config.each do |config_key, config_value|
-
config_type = general_configs.find { |c| c['name'] == config_key }&.dig('type')
-
config[config_key] = ActiveRecord::Type::Boolean.new.cast(config_value) if config_type == 'boolean'
-
end
-
end
-
-
def load_from_cache(config_key)
-
cache_key = "#{VERSION}:#{KEY_PREFIX}:#{config_key}"
-
cached_value = $alfred.with { |conn| conn.get(cache_key) }
-
-
if cached_value.blank?
-
value_from_db = db_fallback(config_key)
-
cached_value = { value: value_from_db }.to_json
-
$alfred.with { |conn| conn.set(cache_key, cached_value, { ex: DEFAULT_EXPIRY }) }
-
end
-
-
JSON.parse(cached_value)['value']
-
end
-
-
def db_fallback(config_key)
-
InstallationConfig.find_by(name: config_key)&.value
-
end
-
end
-
end
-
class GlobalConfigService
-
def self.load(config_key, default_value)
-
config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] }
-
return config if config.present?
-
-
# To support migrating existing instance relying on env variables
-
# TODO: deprecate this later down the line
-
config_value = ENV.fetch(config_key) { default_value }
-
-
return if config_value.blank?
-
-
i = InstallationConfig.where(name: config_key).first_or_create(value: config_value, locked: false)
-
# To clear a nil value that might have been cached in the previous call
-
GlobalConfig.clear_cache
-
i.value
-
end
-
end
-
class Integrations::BotProcessorService
-
pattr_initialize [:event_name!, :hook!, :event_data!]
-
-
def perform
-
message = event_data[:message]
-
return unless should_run_processor?(message)
-
-
process_content(message)
-
rescue StandardError => e
-
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
-
end
-
-
private
-
-
def should_run_processor?(message)
-
return if message.private?
-
return unless processable_message?(message)
-
return unless conversation.pending?
-
-
true
-
end
-
-
def conversation
-
message = event_data[:message]
-
@conversation ||= message.conversation
-
end
-
-
def process_content(message)
-
content = message_content(message)
-
response = get_response(conversation.contact_inbox.source_id, content) if content.present?
-
process_response(message, response) if response.present?
-
end
-
-
def message_content(message)
-
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
-
# cause the message.updated event could be that that the message was deleted
-
-
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
-
-
message.content
-
end
-
-
def processable_message?(message)
-
# TODO: change from reportable and create a dedicated method for this?
-
return unless message.reportable?
-
return if message.outgoing? && !processable_outgoing_message?(message)
-
-
true
-
end
-
-
def processable_outgoing_message?(message)
-
event_name == 'message.updated' && ['input_select'].include?(message.content_type)
-
end
-
-
def process_action(message, action)
-
case action
-
when 'handoff'
-
message.conversation.bot_handoff!
-
when 'resolve'
-
message.conversation.resolved!
-
end
-
end
-
end
-
class Integrations::Captain::ProcessorService < Integrations::BotProcessorService
-
pattr_initialize [:event_name!, :hook!, :event_data!]
-
-
private
-
-
def get_response(_session_id, message_content)
-
call_captain(message_content)
-
end
-
-
def process_response(message, response)
-
if response == 'conversation_handoff'
-
message.conversation.bot_handoff!
-
else
-
create_conversation(message, { content: response })
-
end
-
end
-
-
def create_conversation(message, content_params)
-
return if content_params.blank?
-
-
conversation = message.conversation
-
conversation.messages.create!(
-
content_params.merge(
-
{
-
message_type: :outgoing,
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id
-
}
-
)
-
)
-
end
-
-
def call_captain(message_content)
-
url = "#{GlobalConfigService.load('CAPTAIN_API_URL',
-
'')}/accounts/#{hook.settings['account_id']}/assistants/#{hook.settings['assistant_id']}/chat"
-
-
headers = {
-
'X-USER-EMAIL' => hook.settings['account_email'],
-
'X-USER-TOKEN' => hook.settings['access_token'],
-
'Content-Type' => 'application/json'
-
}
-
-
body = {
-
message: message_content,
-
previous_messages: previous_messages
-
}
-
-
response = HTTParty.post(url, headers: headers, body: body.to_json)
-
response.parsed_response['message']
-
end
-
-
def previous_messages
-
previous_messages = []
-
conversation.messages.where(message_type: [:outgoing, :incoming]).where(private: false).offset(1).find_each do |message|
-
next if message.content_type != 'text'
-
-
role = determine_role(message)
-
previous_messages << { message: message.content, type: role }
-
end
-
previous_messages
-
end
-
-
def determine_role(message)
-
message.message_type == 'incoming' ? 'User' : 'Bot'
-
end
-
end
-
module Integrations::Claude
-
class ProcessorService < ClaudeBaseService
-
def build_messages
-
[
-
{
-
role: 'user',
-
content: event[:message].content
-
}
-
]
-
end
-
-
def system_prompt
-
<<~PROMPT
-
You are a helpful customer support assistant. Be concise and professional in your responses.
-
Current conversation context:
-
#{event[:conversation_context]}
-
PROMPT
-
end
-
-
def valid_event?
-
event[:event] == 'reply_suggestion' && event[:message].present?
-
end
-
-
def process_response(response)
-
JSON.parse(response.body)['content'].first['text']
-
end
-
end
-
end
-
module Integrations
-
class ClaudeBaseService
-
API_URL = ENV.fetch('CLAUDE_API_URL', 'https://api.anthropic.com/v1/messages')
-
MODEL = ENV.fetch('CLAUDE_MODEL', 'claude-3-opus-20240229')
-
TOKEN_LIMIT = ENV.fetch('CLAUDE_TOKEN_LIMIT', 200_000).to_i
-
REQUEST_TIMEOUT = ENV.fetch('CLAUDE_REQUEST_TIMEOUT', 30).to_i
-
MAX_RETRIES = ENV.fetch('CLAUDE_MAX_RETRIES', 2).to_i
-
RETRY_BASE_DELAY = ENV.fetch('CLAUDE_RETRY_DELAY', 0.5).to_f
-
-
pattr_initialize [:hook!, :event!]
-
-
def perform
-
validate_request!
-
make_api_call(build_request_body)
-
end
-
-
private
-
-
def validate_request!
-
raise ArgumentError, 'Invalid event' unless valid_event?
-
raise ArgumentError, 'Missing API key' if hook.settings['api_key'].blank?
-
end
-
-
def build_request_body
-
{
-
model: MODEL,
-
messages: build_messages,
-
max_tokens: TOKEN_LIMIT,
-
system: system_prompt
-
}.compact.to_json
-
end
-
-
def make_api_call(body)
-
with_retries do
-
HTTParty.post(
-
API_URL,
-
headers: {
-
'Content-Type' => 'application/json',
-
'Authorization' => "Bearer #{hook.settings['api_key']}",
-
'X-Request-ID': SecureRandom.uuid,
-
'anthropic-version': '2023-06-01'
-
},
-
body: body,
-
timeout: REQUEST_TIMEOUT
-
)
-
end
-
end
-
-
def with_retries
-
retries = 0
-
begin
-
yield
-
rescue HTTParty::Error, Net::ReadTimeout => e
-
retries += 1
-
if retries <= MAX_RETRIES
-
sleep(RETRY_BASE_DELAY * (2 ** (retries - 1)))
-
retry
-
end
-
raise
-
end
-
end
-
-
# Abstract methods to be implemented by specific services
-
def build_messages
-
raise NotImplementedError
-
end
-
-
def system_prompt
-
raise NotImplementedError
-
end
-
-
def valid_event?
-
raise NotImplementedError
-
end
-
end
-
end
-
1
class Integrations::Deepseek::ProcessorService < Integrations::DeepseekBaseService
-
1
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
-
1
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
-
-
1
def reply_suggestion_message
-
1
make_api_call(reply_suggestion_body)
-
end
-
-
1
def summarize_message
-
1
make_api_call(summarize_body)
-
end
-
-
1
def rephrase_message
-
1
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
def fix_spelling_grammar_message
-
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
def shorten_message
-
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
def expand_message
-
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
def make_friendly_message
-
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
def make_formal_message
-
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
def simplify_message
-
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. #{LANGUAGE_INSTRUCTION}"))
-
end
-
-
1
private
-
-
1
def prompt_from_file(file_name, enterprise: false)
-
2
path = enterprise ? 'enterprise/lib/enterprise/integrations/deepseek_prompts' : 'lib/integrations/deepseek/deepseek_prompts'
-
2
Rails.root.join(path, "#{file_name}.txt").read
-
end
-
-
1
def build_api_call_body(system_content, user_content = event['data']['content'])
-
{
-
1
model: MODEL,
-
messages: [
-
{ role: 'system', content: system_content },
-
{ role: 'user', content: user_content }
-
]
-
}.to_json
-
end
-
-
1
def conversation_messages(in_array_format: false)
-
2
messages = init_messages_body(in_array_format)
-
2
add_messages_until_token_limit(conversation, messages, in_array_format)
-
end
-
-
1
def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0)
-
2
character_count = start_from
-
2
conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message|
-
4
character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format)
-
4
break unless message_added
-
end
-
2
messages
-
end
-
-
1
def add_message_if_within_limit(character_count, message, messages, in_array_format)
-
4
if valid_message?(message, character_count)
-
4
add_message_to_list(message, messages, in_array_format)
-
4
character_count += message.content.length
-
4
[character_count, true]
-
else
-
[character_count, false]
-
end
-
end
-
-
1
def valid_message?(message, character_count)
-
4
message.content.present? && character_count + message.content.length <= TOKEN_LIMIT
-
end
-
-
1
def add_message_to_list(message, messages, in_array_format)
-
4
formatted_message = format_message(message, in_array_format)
-
4
messages.prepend(formatted_message)
-
end
-
-
1
def init_messages_body(in_array_format)
-
2
in_array_format ? [] : ''
-
end
-
-
1
def format_message(message, in_array_format)
-
4
in_array_format ? format_message_in_array(message) : format_message_in_string(message)
-
end
-
-
1
def format_message_in_array(message)
-
2
{ role: (message.incoming? ? 'user' : 'assistant'), content: message.content }
-
end
-
-
1
def format_message_in_string(message)
-
2
sender_type = message.incoming? ? 'Customer' : 'Agent'
-
2
"#{sender_type} #{message.sender&.name} : #{message.content}\n"
-
end
-
-
1
def summarize_body
-
{
-
1
model: MODEL,
-
messages: [
-
{ role: 'system', content: prompt_from_file('summary', enterprise: false) },
-
{ role: 'user', content: conversation_messages }
-
]
-
}.to_json
-
end
-
-
1
def reply_suggestion_body
-
{
-
1
model: MODEL,
-
messages: [
-
{ role: 'system', content: prompt_from_file('reply', enterprise: false) }
-
].concat(conversation_messages(in_array_format: true))
-
}.to_json
-
end
-
end
-
-
1
Integrations::Deepseek::ProcessorService.prepend_mod_with('Integrations::DeepseekProcessorService')
-
1
class Integrations::DeepseekBaseService
-
1
TOKEN_LIMIT = 500_000
-
1
API_URL = ENV.fetch('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions')
-
1
MODEL = ENV.fetch('DEEPSEEK_MODEL', 'deepseek-pro')
-
-
1
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
-
1
CACHEABLE_EVENTS = %w[].freeze
-
-
1
pattr_initialize [:hook!, :event!]
-
-
1
def perform
-
3
return nil unless valid_event_name?
-
3
return value_from_cache if value_from_cache.present?
-
-
3
response = send("#{event_name}_message")
-
3
save_to_cache(response) if response.present?
-
-
3
response
-
end
-
-
1
private
-
-
1
def event_name
-
12
event['name']
-
end
-
-
1
def cache_key
-
return nil unless event_is_cacheable?
-
return nil unless conversation
-
-
format(::Redis::Alfred::DEEPSEEK_CONVERSATION_KEY,
-
event_name: event_name,
-
conversation_id: conversation.id,
-
updated_at: conversation.last_activity_at.to_i)
-
end
-
-
1
def value_from_cache
-
3
return nil unless event_is_cacheable?
-
return nil if cache_key.blank?
-
-
deserialize_cached_value(Redis::Alfred.get(cache_key))
-
end
-
-
1
def deserialize_cached_value(value)
-
return nil if value.blank?
-
-
JSON.parse(value, symbolize_names: true)
-
rescue JSON::ParserError
-
nil
-
end
-
-
1
def save_to_cache(response)
-
3
return nil unless event_is_cacheable?
-
-
Redis::Alfred.setex(cache_key, response.to_json)
-
end
-
-
1
def conversation
-
2
@conversation ||= hook.account.conversations.find_by(display_id: event['data']['conversation_display_id'])
-
end
-
-
1
def valid_event_name?
-
3
self.class::ALLOWED_EVENT_NAMES.include?(event_name)
-
end
-
-
1
def event_is_cacheable?
-
6
self.class::CACHEABLE_EVENTS.include?(event_name)
-
end
-
-
1
def make_api_call(body)
-
headers = {
-
3
'Content-Type' => 'application/json',
-
'Authorization' => "Bearer #{hook.settings['api_key']}"
-
}
-
-
3
Rails.logger.info("Deepseek API request: #{body}")
-
3
response = HTTParty.post(API_URL, headers: headers, body: body)
-
3
Rails.logger.info("Deepseek API response: #{response.body}")
-
-
3
return { error: response.parsed_response, error_code: response.code } unless response.success?
-
-
3
choices = JSON.parse(response.body)['choices']
-
3
choices.present? ? { message: choices.first['message']['content'] } : { message: nil }
-
end
-
end
-
require 'google/cloud/dialogflow/v2'
-
-
class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorService
-
pattr_initialize [:event_name!, :hook!, :event_data!]
-
-
private
-
-
def message_content(message)
-
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
-
# cause the message.updated event could be that that the message was deleted
-
-
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
-
-
message.content
-
end
-
-
def get_response(session_id, message_content)
-
if hook.settings['credentials'].blank?
-
Rails.logger.warn "Account: #{hook.try(:account_id)} Hook: #{hook.id} credentials are not present." && return
-
end
-
-
configure_dialogflow_client_defaults
-
detect_intent(session_id, message_content)
-
rescue Google::Cloud::PermissionDeniedError => e
-
Rails.logger.warn "DialogFlow Error: (account-#{hook.try(:account_id)}, hook-#{hook.id}) #{e.message}"
-
hook.prompt_reauthorization!
-
hook.disable
-
end
-
-
def process_response(message, response)
-
fulfillment_messages = response.query_result['fulfillment_messages']
-
fulfillment_messages.each do |fulfillment_message|
-
content_params = generate_content_params(fulfillment_message)
-
if content_params['action'].present?
-
process_action(message, content_params['action'])
-
else
-
create_conversation(message, content_params)
-
end
-
end
-
end
-
-
def generate_content_params(fulfillment_message)
-
text_response = fulfillment_message['text'].to_h
-
content_params = { content: text_response[:text].first } if text_response[:text].present?
-
content_params ||= fulfillment_message['payload'].to_h
-
content_params
-
end
-
-
def create_conversation(message, content_params)
-
return if content_params.blank?
-
-
conversation = message.conversation
-
conversation.messages.create!(
-
content_params.merge(
-
{
-
message_type: :outgoing,
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id
-
}
-
)
-
)
-
end
-
-
def configure_dialogflow_client_defaults
-
::Google::Cloud::Dialogflow::V2::Sessions::Client.configure do |config|
-
config.timeout = 10.0
-
config.credentials = hook.settings['credentials']
-
end
-
end
-
-
def detect_intent(session_id, message)
-
client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new
-
session = "projects/#{hook.settings['project_id']}/agent/sessions/#{session_id}"
-
query_input = { text: { text: message, language_code: 'en-US' } }
-
client.detect_intent session: session, query_input: query_input
-
end
-
end
-
class Integrations::Dyte::ProcessorService
-
pattr_initialize [:account!, :conversation!]
-
-
def create_a_meeting(agent)
-
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
-
response = dyte_client.create_a_meeting(title)
-
-
return response if response[:error].present?
-
-
meeting = response
-
message = create_a_dyte_integration_message(meeting, title, agent)
-
message.push_event_data
-
end
-
-
def add_participant_to_meeting(meeting_id, user)
-
dyte_client.add_participant_to_meeting(meeting_id, user.id, user.name, avatar_url(user))
-
end
-
-
private
-
-
def create_a_dyte_integration_message(meeting, title, agent)
-
@conversation.messages.create!(
-
{
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
message_type: :outgoing,
-
content_type: :integrations,
-
content: title,
-
content_attributes: {
-
type: 'dyte',
-
data: {
-
meeting_id: meeting['id']
-
}
-
},
-
sender: agent
-
}
-
)
-
end
-
-
def avatar_url(user)
-
return user.avatar_url if user.avatar_url.present?
-
-
"#{ENV.fetch('FRONTEND_URL', nil)}/integrations/slack/user.png"
-
end
-
-
def dyte_hook
-
@dyte_hook ||= account.hooks.find_by!(app_id: 'dyte')
-
end
-
-
def dyte_client
-
credentials = dyte_hook.settings
-
@dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key'])
-
end
-
end
-
# frozen_string_literal: true
-
-
class Integrations::Facebook::DeliveryStatus
-
pattr_initialize [:params!]
-
-
def perform
-
return if facebook_channel.blank?
-
return unless conversation
-
-
process_delivery_status if params.delivery_watermark
-
process_read_status if params.read_watermark
-
end
-
-
private
-
-
def process_delivery_status
-
timestamp = Time.zone.at(params.delivery_watermark.to_i).to_datetime.utc
-
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :delivered)
-
end
-
-
def process_read_status
-
timestamp = Time.zone.at(params.read_watermark.to_i).to_datetime.utc
-
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :read)
-
end
-
-
def contact
-
::ContactInbox.find_by(source_id: params.sender_id)&.contact
-
end
-
-
def conversation
-
@conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present?
-
end
-
-
def facebook_channel
-
@facebook_channel ||= Channel::FacebookPage.find_by(page_id: params.recipient_id)
-
end
-
end
-
# frozen_string_literal: true
-
-
class Integrations::Facebook::MessageCreator
-
attr_reader :response
-
-
def initialize(response)
-
@response = response
-
end
-
-
def perform
-
# begin
-
if agent_message_via_echo?
-
create_agent_message
-
else
-
create_contact_message
-
end
-
# rescue => e
-
# ChatwootExceptionTracker.new(e).capture_exception
-
# end
-
end
-
-
private
-
-
def agent_message_via_echo?
-
# TODO : check and remove send_from_chatwoot_app if not working
-
response.echo? && !response.sent_from_chatwoot_app?
-
# this means that it is an agent message from page, but not sent from chatwoot.
-
# User can send from fb page directly on mobile / web messenger, so this case should be handled as agent message
-
end
-
-
def create_agent_message
-
Channel::FacebookPage.where(page_id: response.sender_id).each do |page|
-
mb = Messages::Facebook::MessageBuilder.new(response, page.inbox, outgoing_echo: true)
-
mb.perform
-
end
-
end
-
-
def create_contact_message
-
Channel::FacebookPage.where(page_id: response.recipient_id).each do |page|
-
mb = Messages::Facebook::MessageBuilder.new(response, page.inbox)
-
mb.perform
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class Integrations::Facebook::MessageParser
-
def initialize(response_json)
-
@response = JSON.parse(response_json)
-
@messaging = @response['messaging'] || @response['standby']
-
end
-
-
def sender_id
-
@messaging.dig('sender', 'id')
-
end
-
-
def recipient_id
-
@messaging.dig('recipient', 'id')
-
end
-
-
def time_stamp
-
@messaging['timestamp']
-
end
-
-
def content
-
@messaging.dig('message', 'text')
-
end
-
-
def sequence
-
@messaging.dig('message', 'seq')
-
end
-
-
def attachments
-
@messaging.dig('message', 'attachments')
-
end
-
-
def identifier
-
@messaging.dig('message', 'mid')
-
end
-
-
def delivery
-
@messaging['delivery']
-
end
-
-
def read
-
@messaging['read']
-
end
-
-
def read_watermark
-
read&.dig('watermark')
-
end
-
-
def delivery_watermark
-
delivery&.dig('watermark')
-
end
-
-
def echo?
-
@messaging.dig('message', 'is_echo')
-
end
-
-
# TODO : i don't think the payload contains app_id. if not remove
-
def app_id
-
@messaging.dig('message', 'app_id')
-
end
-
-
# TODO : does this work ?
-
def sent_from_chatwoot_app?
-
app_id && app_id == GlobalConfigService.load('FB_APP_ID', '').to_i
-
end
-
-
def in_reply_to_external_id
-
@messaging.dig('message', 'reply_to', 'mid')
-
end
-
end
-
-
# Sample Response
-
# {
-
# "sender":{
-
# "id":"USER_ID"
-
# },
-
# "recipient":{
-
# "id":"PAGE_ID"
-
# },
-
# "timestamp":1458692752478,
-
# "message":{
-
# "mid":"mid.1457764197618:41d102a3e1ae206a38",
-
# "seq":73,
-
# "text":"hello, world!",
-
# "quick_reply": {
-
# "payload": "DEVELOPER_DEFINED_PAYLOAD"
-
# }
-
# }
-
# }
-
require 'google/cloud/translate/v3'
-
class Integrations::GoogleTranslate::DetectLanguageService
-
pattr_initialize [:hook!, :message!]
-
-
def perform
-
return unless valid_message?
-
return if conversation.additional_attributes['conversation_language'].present?
-
-
text = message.content[0...1500]
-
response = client.detect_language(
-
content: text,
-
parent: "projects/#{hook.settings['project_id']}"
-
)
-
-
update_conversation(response)
-
end
-
-
private
-
-
def valid_message?
-
message.incoming? && message.content.present?
-
end
-
-
def conversation
-
@conversation ||= message.conversation
-
end
-
-
def update_conversation(response)
-
return if response&.languages.blank?
-
-
conversation_language = response.languages.first.language_code
-
additional_attributes = conversation.additional_attributes.merge({ conversation_language: conversation_language })
-
conversation.update!(additional_attributes: additional_attributes)
-
end
-
-
def client
-
@client ||= ::Google::Cloud::Translate::V3::TranslationService::Client.new do |config|
-
config.credentials = hook.settings['credentials']
-
end
-
end
-
end
-
require 'google/cloud/translate/v3'
-
-
class Integrations::GoogleTranslate::ProcessorService
-
pattr_initialize [:message!, :target_language!]
-
-
def perform
-
return if hook.blank?
-
-
content = translation_content
-
return if content.blank?
-
-
response = client.translate_text(
-
contents: [content],
-
target_language_code: target_language,
-
parent: "projects/#{hook.settings['project_id']}",
-
mime_type: mime_type
-
)
-
-
return if response.translations.first.blank?
-
-
response.translations.first.translated_text
-
end
-
-
private
-
-
def email_channel?
-
message&.inbox&.email?
-
end
-
-
def email_content
-
@email_content ||= {
-
html: message.content_attributes.dig('email', 'html_content', 'full'),
-
text: message.content_attributes.dig('email', 'text_content', 'full'),
-
content_type: message.content_attributes.dig('email', 'content_type')
-
}
-
end
-
-
def html_content_available?
-
email_content[:html].present?
-
end
-
-
def plain_text_content_available?
-
email_content[:content_type]&.include?('text/plain') &&
-
email_content[:text].present?
-
end
-
-
def translation_content
-
return message.content unless email_channel?
-
return email_content[:html] if html_content_available?
-
return email_content[:text] if plain_text_content_available?
-
-
message.content
-
end
-
-
def mime_type
-
if email_channel? && html_content_available?
-
'text/html'
-
else
-
'text/plain'
-
end
-
end
-
-
def hook
-
@hook ||= message.account.hooks.find_by(app_id: 'google_translate')
-
end
-
-
def client
-
@client ||= ::Google::Cloud::Translate::V3::TranslationService::Client.new do |config|
-
config.credentials = hook.settings['credentials']
-
end
-
end
-
end
-
class Integrations::Linear::ProcessorService
-
pattr_initialize [:account!]
-
-
def teams
-
response = linear_client.teams
-
return { error: response[:error] } if response[:error]
-
-
{ data: response['teams']['nodes'].map(&:as_json) }
-
end
-
-
def team_entities(team_id)
-
response = linear_client.team_entities(team_id)
-
return response if response[:error]
-
-
{
-
data: {
-
users: response['users']['nodes'].map(&:as_json),
-
projects: response['projects']['nodes'].map(&:as_json),
-
states: response['workflowStates']['nodes'].map(&:as_json),
-
labels: response['issueLabels']['nodes'].map(&:as_json)
-
}
-
}
-
end
-
-
def create_issue(params)
-
response = linear_client.create_issue(params)
-
return response if response[:error]
-
-
{
-
data: { id: response['issueCreate']['issue']['id'],
-
title: response['issueCreate']['issue']['title'] }
-
}
-
end
-
-
def link_issue(link, issue_id, title)
-
response = linear_client.link_issue(link, issue_id, title)
-
return response if response[:error]
-
-
{
-
data: {
-
id: issue_id,
-
link: link,
-
link_id: response.with_indifferent_access[:attachmentLinkURL][:attachment][:id]
-
}
-
}
-
end
-
-
def unlink_issue(link_id)
-
response = linear_client.unlink_issue(link_id)
-
return response if response[:error]
-
-
{
-
data: { link_id: link_id }
-
}
-
end
-
-
def search_issue(term)
-
response = linear_client.search_issue(term)
-
-
return response if response[:error]
-
-
{ data: response['searchIssues']['nodes'].map(&:as_json) }
-
end
-
-
def linked_issues(url)
-
response = linear_client.linked_issues(url)
-
return response if response[:error]
-
-
{ data: response['attachmentsForURL']['nodes'].map(&:as_json) }
-
end
-
-
private
-
-
def linear_hook
-
@linear_hook ||= account.hooks.find_by!(app_id: 'linear')
-
end
-
-
def linear_client
-
@linear_client ||= Linear.new(linear_hook.access_token)
-
end
-
end
-
class Integrations::Slack::ChannelBuilder
-
attr_reader :params, :channel
-
-
def initialize(params)
-
@params = params
-
end
-
-
def fetch_channels
-
channels
-
end
-
-
def update(reference_id)
-
update_reference_id(reference_id)
-
end
-
-
private
-
-
def hook
-
@hook ||= params[:hook]
-
end
-
-
def slack_client
-
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
-
end
-
-
def channels
-
conversations_list = slack_client.conversations_list(types: 'public_channel, private_channel', exclude_archived: true)
-
channel_list = conversations_list.channels
-
while conversations_list.response_metadata.next_cursor.present?
-
conversations_list = slack_client.conversations_list(cursor: conversations_list.response_metadata.next_cursor)
-
channel_list.concat(conversations_list.channels)
-
end
-
channel_list
-
end
-
-
def find_channel(reference_id)
-
channels.find { |channel| channel['id'] == reference_id }
-
end
-
-
def update_reference_id(reference_id)
-
channel = find_channel(reference_id)
-
return if channel.blank?
-
-
slack_client.conversations_join(channel: channel[:id]) if channel[:is_private] == false
-
@hook.update!(reference_id: channel[:id], settings: { channel_name: channel[:name] }, status: 'enabled')
-
@hook
-
end
-
end
-
class Integrations::Slack::HookBuilder
-
attr_reader :params
-
-
def initialize(params)
-
@params = params
-
end
-
-
def perform
-
token = fetch_access_token
-
-
hook = account.hooks.new(
-
access_token: token,
-
status: 'disabled',
-
inbox_id: params[:inbox_id],
-
app_id: 'slack'
-
)
-
-
hook.save!
-
hook
-
end
-
-
private
-
-
def account
-
params[:account]
-
end
-
-
def hook_type
-
params[:inbox_id] ? 'inbox' : 'account'
-
end
-
-
def fetch_access_token
-
client = Slack::Web::Client.new
-
slack_access = client.oauth_v2_access(
-
client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
-
client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
-
code: params[:code],
-
redirect_uri: Integrations::App.slack_integration_url
-
)
-
slack_access['access_token']
-
end
-
end
-
class Integrations::Slack::LinkUnfurlFormatter
-
pattr_initialize [:url!, :user_info!, :inbox_name!, :inbox_type!]
-
-
def perform
-
return {} if url.blank?
-
-
{
-
url => {
-
'blocks' => preivew_blocks(user_info) +
-
open_conversation_button(url)
-
}
-
}
-
end
-
-
private
-
-
def preivew_blocks(user_info)
-
[
-
{
-
'type' => 'section',
-
'fields' => [
-
preview_field(I18n.t('slack_unfurl.fields.name'), user_info[:user_name]),
-
preview_field(I18n.t('slack_unfurl.fields.email'), user_info[:email]),
-
preview_field(I18n.t('slack_unfurl.fields.phone_number'), user_info[:phone_number]),
-
preview_field(I18n.t('slack_unfurl.fields.company_name'), user_info[:company_name]),
-
preview_field(I18n.t('slack_unfurl.fields.inbox_name'), inbox_name),
-
preview_field(I18n.t('slack_unfurl.fields.inbox_type'), inbox_type)
-
]
-
}
-
]
-
end
-
-
def preview_field(label, value)
-
{
-
'type' => 'mrkdwn',
-
'text' => "*#{label}:*\n#{value}"
-
}
-
end
-
-
def open_conversation_button(url)
-
[
-
{
-
'type' => 'actions',
-
'elements' => [
-
{
-
'type' => 'button',
-
'text' => {
-
'type' => 'plain_text',
-
'text' => I18n.t('slack_unfurl.button'),
-
'emoji' => true
-
},
-
'url' => url,
-
'action_id' => 'button-action'
-
}
-
]
-
}
-
]
-
end
-
end
-
class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
-
include RegexHelper
-
pattr_initialize [:message!, :hook!]
-
-
def perform
-
# overriding the base class logic since the validations are different in this case.
-
# FIXME: for now we will only send messages from widget to slack
-
return unless valid_channel_for_slack?
-
# we don't want message loop in slack
-
return if message.external_source_id_slack.present?
-
# we don't want to start slack thread from agent conversation as of now
-
return if invalid_message?
-
-
perform_reply
-
end
-
-
def link_unfurl(event)
-
slack_client.chat_unfurl(
-
event
-
)
-
# You may wonder why we're not requesting reauthorization and disabling hooks when scope errors occur.
-
# Since link unfurling is just a nice-to-have feature that doesn't affect core functionality, we will silently ignore these errors.
-
rescue Slack::Web::Api::Errors::MissingScope => e
-
Rails.logger.warn "Slack: Missing scope error: #{e.message}"
-
end
-
-
private
-
-
def valid_channel_for_slack?
-
# slack wouldn't be an ideal interface to reply to tweets, hence disabling that case
-
return false if channel.is_a?(Channel::TwitterProfile) && conversation.additional_attributes['type'] == 'tweet'
-
-
true
-
end
-
-
def invalid_message?
-
(message.outgoing? || message.template?) && conversation.identifier.blank?
-
end
-
-
def perform_reply
-
send_message
-
-
return unless @slack_message
-
-
update_reference_id
-
update_external_source_id_slack
-
end
-
-
def message_content
-
private_indicator = message.private? ? 'private: ' : ''
-
sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content)
-
-
if conversation.identifier.present?
-
"#{private_indicator}#{sanitized_content}"
-
else
-
"#{formatted_inbox_name}#{formatted_conversation_link}#{email_subject_line}\n#{sanitized_content}"
-
end
-
end
-
-
def format_message_content
-
message.message_type == 'activity' ? "_#{message_text}_" : message_text
-
end
-
-
def message_text
-
content = message.processed_message_content || message.content
-
-
if content.present?
-
content.gsub(MENTION_REGEX, '\1')
-
else
-
content
-
end
-
end
-
-
def formatted_inbox_name
-
"\n*Inbox:* #{message.inbox.name} (#{message.inbox.inbox_type})\n"
-
end
-
-
def formatted_conversation_link
-
"#{link_to_conversation} to view the conversation.\n"
-
end
-
-
def email_subject_line
-
return '' unless message.inbox.email?
-
-
email_payload = message.content_attributes['email']
-
return "*Subject:* #{email_payload['subject']}\n\n" if email_payload.present? && email_payload['subject'].present?
-
-
''
-
end
-
-
def avatar_url(sender)
-
sender_type = sender_type(sender).downcase
-
blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil
-
generate_url(sender_type, blob_key)
-
end
-
-
def generate_url(sender_type, blob_key)
-
base_url = ENV.fetch('FRONTEND_URL', nil)
-
"#{base_url}/slack_uploads?blob_key=#{blob_key}&sender_type=#{sender_type}"
-
end
-
-
def send_message
-
post_message if message_content.present?
-
upload_file if message.attachments.any?
-
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
-
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
-
Rails.logger.error e
-
hook.prompt_reauthorization!
-
hook.disable
-
end
-
-
def post_message
-
@slack_message = slack_client.chat_postMessage(
-
channel: hook.reference_id,
-
text: message_content,
-
username: sender_name(message.sender),
-
thread_ts: conversation.identifier,
-
icon_url: avatar_url(message.sender),
-
unfurl_links: conversation.identifier.present?
-
)
-
end
-
-
def upload_file
-
return unless message.attachments.first.with_attached_file?
-
-
result = slack_client.files_upload_v2(
-
filename: message.attachments.first.file.filename,
-
content: message.attachments.first.file.download,
-
initial_comment: 'Attached File!',
-
thread_ts: conversation.identifier,
-
channel_id: hook.reference_id
-
)
-
Rails.logger.info "slack_upload_result: #{result}"
-
end
-
-
def file_type
-
File.extname(message.attachments.first.download_url).strip.downcase[1..]
-
end
-
-
def file_information
-
{
-
filename: message.attachments.first.file.filename,
-
filetype: file_type,
-
content: message.attachments.first.file.download,
-
title: message.attachments.first.file.filename
-
}
-
end
-
-
def sender_name(sender)
-
sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender)
-
end
-
-
def sender_type(sender)
-
if sender.instance_of?(Contact)
-
'Contact'
-
elsif message.message_type == 'template' && sender.nil?
-
'Bot'
-
elsif message.message_type == 'activity' && sender.nil?
-
'System'
-
else
-
'Agent'
-
end
-
end
-
-
def update_reference_id
-
return unless should_update_reference_id?
-
-
conversation.update!(identifier: @slack_message['ts'])
-
end
-
-
def update_external_source_id_slack
-
return unless @slack_message['message']
-
-
message.update!(external_source_id_slack: "cw-origin-#{@slack_message['message']['ts']}")
-
end
-
-
def slack_client
-
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
-
end
-
-
def link_to_conversation
-
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{conversation.account_id}/conversations/#{conversation.display_id}|Click here>"
-
end
-
-
# Determines whether the conversation identifier should be updated with the ts value.
-
# The identifier should be updated in the following cases:
-
# - If the conversation identifier is blank, it means a new conversation is being created.
-
# - If the thread_ts is blank, it means that the conversation was previously connected in a different channel.
-
def should_update_reference_id?
-
conversation.identifier.blank? || @slack_message['message']['thread_ts'].blank?
-
end
-
end
-
class Integrations::Slack::SlackLinkUnfurlService
-
pattr_initialize [:params!, :integration_hook!]
-
-
def perform
-
event_links = params.dig(:event, :links)
-
return unless event_links
-
-
event_links.each do |link_info|
-
url = link_info[:url]
-
# Unfurl only if the account id is same as the integration hook account id
-
unfurl_link(url) if url && valid_account?(url)
-
end
-
end
-
-
def unfurl_link(url)
-
conversation = conversation_from_url(url)
-
return unless conversation
-
-
send_unfurls(url, conversation)
-
end
-
-
private
-
-
def contact_attributes(conversation)
-
contact = conversation.contact
-
{
-
user_name: contact.name.presence || '---',
-
email: contact.email.presence || '---',
-
phone_number: contact.phone_number.presence || '---',
-
company_name: contact.additional_attributes&.dig('company_name').presence || '---'
-
}
-
end
-
-
def generate_unfurls(url, user_info, inbox)
-
Integrations::Slack::LinkUnfurlFormatter.new(
-
url: url,
-
user_info: user_info,
-
inbox_name: inbox.name,
-
inbox_type: inbox.channel.name
-
).perform
-
end
-
-
def send_unfurls(url, conversation)
-
user_info = contact_attributes(conversation)
-
unfurls = generate_unfurls(url, user_info, conversation.inbox)
-
unfurl_params = {
-
unfurl_id: params.dig(:event, :unfurl_id),
-
source: params.dig(:event, :source),
-
unfurls: JSON.generate(unfurls)
-
}
-
-
slack_service = Integrations::Slack::SendOnSlackService.new(
-
message: nil,
-
hook: integration_hook
-
)
-
slack_service.link_unfurl(unfurl_params)
-
end
-
-
def conversation_from_url(url)
-
conversation_id = extract_conversation_id(url)
-
find_conversation_by_id(conversation_id) if conversation_id
-
end
-
-
def find_conversation_by_id(conversation_id)
-
Conversation.find_by(display_id: conversation_id, account_id: integration_hook.account_id)
-
end
-
-
def valid_account?(url)
-
account_id = extract_account_id(url)
-
account_id == integration_hook.account_id.to_s
-
end
-
-
def extract_account_id(url)
-
account_id_regex = %r{/accounts/(\d+)}
-
match_data = url.match(account_id_regex)
-
match_data[1] if match_data
-
end
-
-
def extract_conversation_id(url)
-
conversation_id_regex = %r{/conversations/(\d+)}
-
match_data = url.match(conversation_id_regex)
-
match_data[1] if match_data
-
end
-
end
-
module Integrations::Slack::SlackMessageHelper
-
def process_message_payload
-
return unless conversation
-
-
handle_conversation
-
success_response
-
rescue Slack::Web::Api::Errors::MissingScope => e
-
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
-
disable_and_reauthorize
-
end
-
-
def handle_conversation
-
create_message unless message_exists?
-
end
-
-
def success_response
-
{ status: 'success' }
-
end
-
-
def disable_and_reauthorize
-
integration_hook.prompt_reauthorization!
-
integration_hook.disable
-
end
-
-
def message_exists?
-
conversation.messages.exists?(external_source_ids: { slack: params[:event][:ts] })
-
end
-
-
def create_message
-
@message = conversation.messages.build(
-
message_type: :outgoing,
-
account_id: conversation.account_id,
-
inbox_id: conversation.inbox_id,
-
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
-
external_source_id_slack: params[:event][:ts],
-
private: private_note?,
-
sender: sender
-
)
-
process_attachments(params[:event][:files]) if attachments_present?
-
@message.save!
-
end
-
-
def attachments_present?
-
params[:event][:files].present?
-
end
-
-
def process_attachments(attachments)
-
attachments.each do |attachment|
-
tempfile = Down::NetHttp.download(attachment[:url_private], headers: { 'Authorization' => "Bearer #{integration_hook.access_token}" })
-
-
attachment_params = {
-
file_type: file_type(attachment),
-
account_id: @message.account_id,
-
external_url: attachment[:url_private],
-
file: {
-
io: tempfile,
-
filename: tempfile.original_filename,
-
content_type: tempfile.content_type
-
}
-
}
-
-
attachment_obj = @message.attachments.new(attachment_params)
-
attachment_obj.file.content_type = attachment[:mimetype]
-
end
-
end
-
-
def file_type(attachment)
-
return if attachment[:mimetype] == 'text/plain'
-
-
case attachment[:filetype]
-
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
-
:image
-
when 'pdf'
-
:file
-
end
-
end
-
-
def conversation
-
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
-
end
-
-
def sender
-
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
-
conversation.account.users.from_email(user_email)
-
end
-
-
def private_note?
-
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
-
end
-
end
-
1
module Limits
-
1
BULK_ACTIONS_LIMIT = 100
-
1
BULK_EXTERNAL_HTTP_CALLS_LIMIT = 25
-
1
URL_LENGTH_LIMIT = 2048 # https://stackoverflow.com/questions/417142
-
1
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
-
1
GREETING_MESSAGE_MAX_LENGTH = 10_000
-
-
1
def self.conversation_message_per_minute_limit
-
6
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
-
end
-
end
-
1
class Linear
-
1
BASE_URL = 'https://api.linear.app/graphql'.freeze
-
1
PRIORITY_LEVELS = (0..4).to_a
-
-
1
def initialize(access_token)
-
@access_token = access_token
-
raise ArgumentError, 'Missing Credentials' if access_token.blank?
-
end
-
-
1
def teams
-
query = {
-
query: Linear::Queries::TEAMS_QUERY
-
}
-
response = post(query)
-
process_response(response)
-
end
-
-
1
def team_entities(team_id)
-
raise ArgumentError, 'Missing team id' if team_id.blank?
-
-
query = {
-
query: Linear::Queries.team_entities_query(team_id)
-
}
-
response = post(query)
-
process_response(response)
-
end
-
-
1
def search_issue(term)
-
raise ArgumentError, 'Missing search term' if term.blank?
-
-
query = {
-
query: Linear::Queries.search_issue(term)
-
}
-
response = post(query)
-
process_response(response)
-
end
-
-
1
def linked_issues(url)
-
raise ArgumentError, 'Missing link' if url.blank?
-
-
query = {
-
query: Linear::Queries.linked_issues(url)
-
}
-
response = post(query)
-
process_response(response)
-
end
-
-
1
def create_issue(params)
-
validate_team_and_title(params)
-
validate_priority(params[:priority])
-
validate_label_ids(params[:label_ids])
-
-
variables = {
-
title: params[:title],
-
teamId: params[:team_id],
-
description: params[:description],
-
assigneeId: params[:assignee_id],
-
priority: params[:priority],
-
labelIds: params[:label_ids],
-
projectId: params[:project_id]
-
}.compact
-
mutation = Linear::Mutations.issue_create(variables)
-
response = post({ query: mutation })
-
process_response(response)
-
end
-
-
1
def link_issue(link, issue_id, title)
-
raise ArgumentError, 'Missing link' if link.blank?
-
raise ArgumentError, 'Missing issue id' if issue_id.blank?
-
-
payload = {
-
query: Linear::Mutations.issue_link(issue_id, link, title)
-
}
-
response = post(payload)
-
process_response(response)
-
end
-
-
1
def unlink_issue(link_id)
-
raise ArgumentError, 'Missing link id' if link_id.blank?
-
-
payload = {
-
query: Linear::Mutations.unlink_issue(link_id)
-
}
-
response = post(payload)
-
process_response(response)
-
end
-
-
1
private
-
-
1
def validate_team_and_title(params)
-
raise ArgumentError, 'Missing team id' if params[:team_id].blank?
-
raise ArgumentError, 'Missing title' if params[:title].blank?
-
end
-
-
1
def validate_priority(priority)
-
return if priority.nil? || PRIORITY_LEVELS.include?(priority)
-
-
raise ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.'
-
end
-
-
1
def validate_label_ids(label_ids)
-
return if label_ids.nil?
-
return if label_ids.is_a?(Array) && label_ids.all?(String)
-
-
raise ArgumentError, 'label_ids must be an array of strings.'
-
end
-
-
1
def post(payload)
-
HTTParty.post(
-
BASE_URL,
-
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' },
-
body: payload.to_json
-
)
-
end
-
-
1
def process_response(response)
-
return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil?
-
-
{ error: response.parsed_response, error_code: response.code }
-
end
-
end
-
module Linear::Mutations
-
def self.graphql_value(value)
-
case value
-
when String
-
# Strings must be enclosed in double quotes
-
"\"#{value.gsub("\n", '\\n')}\""
-
when Array
-
# Arrays need to be recursively converted
-
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
-
else
-
# Other types (numbers, booleans) can be directly converted to strings
-
value.to_s
-
end
-
end
-
-
def self.graphql_input(input)
-
input.map { |key, value| "#{key}: #{graphql_value(value)}" }.join(', ')
-
end
-
-
def self.issue_create(input)
-
<<~GRAPHQL
-
mutation {
-
issueCreate(input: { #{graphql_input(input)} }) {
-
success
-
issue {
-
id
-
title
-
}
-
}
-
}
-
GRAPHQL
-
end
-
-
def self.issue_link(issue_id, link, title)
-
<<~GRAPHQL
-
mutation {
-
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}") {
-
success
-
attachment {
-
id
-
}
-
}
-
}
-
GRAPHQL
-
end
-
-
def self.unlink_issue(link_id)
-
<<~GRAPHQL
-
mutation {
-
attachmentDelete(id: "#{link_id}") {
-
success
-
}
-
}
-
GRAPHQL
-
end
-
end
-
# Copyright (c) Microsoft Corporation.
-
# Licensed under the MIT License.
-
# frozen_string_literal: true
-
-
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp
-
-
require 'omniauth-oauth2'
-
-
# Implements an OmniAuth strategy to get a Microsoft Graph
-
# compatible token from Azure AD
-
class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2
-
option :name, :microsoft_graph_auth
-
-
DEFAULT_SCOPE = 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send'
-
-
# Configure the Microsoft identity platform endpoints
-
option :client_options,
-
site: 'https://login.microsoftonline.com',
-
authorize_url: '/common/oauth2/v2.0/authorize',
-
token_url: '/common/oauth2/v2.0/token'
-
-
option :pcke, true
-
# Send the scope parameter during authorize
-
option :authorize_options, [:scope]
-
-
# Unique ID for the user is the id field
-
uid { raw_info['id'] }
-
-
# Get additional information after token is retrieved
-
extra do
-
{
-
'raw_info' => raw_info
-
}
-
end
-
-
def raw_info
-
# Get user profile information from the /me endpoint
-
@raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me?$select=displayName').parsed
-
end
-
-
def authorize_params
-
super.tap do |params|
-
params[:scope] = request.params['scope'] if request.params['scope']
-
params[:scope] ||= DEFAULT_SCOPE
-
end
-
end
-
-
# Override callback URL
-
# OmniAuth by default passes the entire URL of the callback, including
-
# query parameters. Azure fails validation because that doesn't match the
-
# registered callback.
-
def callback_url
-
ENV.fetch('FRONTEND_URL', nil) + app_path
-
end
-
end
-
1
class OnlineStatusTracker
-
# NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
-
1
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
-
-
# presence : sorted set with timestamp as the score & object id as value
-
-
# obj_type: Contact | User
-
1
def self.update_presence(account_id, obj_type, obj_id)
-
::Redis::Alfred.zadd(presence_key(account_id, obj_type), Time.now.to_i, obj_id)
-
end
-
-
1
def self.get_presence(account_id, obj_type, obj_id)
-
connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
-
connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
-
end
-
-
1
def self.presence_key(account_id, type)
-
3
case type
-
when 'Contact'
-
format(::Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account_id)
-
else
-
3
format(::Redis::Alfred::ONLINE_PRESENCE_USERS, account_id: account_id)
-
end
-
end
-
-
# online status : online | busy | offline
-
# redis hash with obj_id key && status as value
-
-
1
def self.set_status(account_id, user_id, status)
-
::Redis::Alfred.hset(status_key(account_id), user_id, status)
-
end
-
-
1
def self.get_status(account_id, user_id)
-
::Redis::Alfred.hget(status_key(account_id), user_id)
-
end
-
-
1
def self.status_key(account_id)
-
format(::Redis::Alfred::ONLINE_STATUS, account_id: account_id)
-
end
-
-
1
def self.get_available_contact_ids(account_id)
-
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
-
# exclusive minimum score is specified by prefixing (
-
# we are clearing old records because this could clogg up the sorted set
-
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
-
::Redis::Alfred.zrangebyscore(presence_key(account_id, 'Contact'), range_start, '+inf')
-
end
-
-
1
def self.get_available_contacts(account_id)
-
# returns {id1: 'online', id2: 'online'}
-
get_available_contact_ids(account_id).index_with { |_id| 'online' }
-
end
-
-
1
def self.get_available_users(account_id)
-
3
user_ids = get_available_user_ids(account_id)
-
-
3
return {} if user_ids.blank?
-
-
user_availabilities = ::Redis::Alfred.hmget(status_key(account_id), user_ids)
-
user_ids.map.with_index { |id, index| [id, (user_availabilities[index] || get_availability_from_db(account_id, id))] }.to_h
-
end
-
-
1
def self.get_availability_from_db(account_id, user_id)
-
availability = Account.find(account_id).account_users.find_by(user_id: user_id).availability
-
set_status(account_id, user_id, availability)
-
availability
-
end
-
-
1
def self.get_available_user_ids(account_id)
-
3
account = Account.find(account_id)
-
3
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
-
3
user_ids = ::Redis::Alfred.zrangebyscore(presence_key(account_id, 'User'), range_start, '+inf')
-
# since we are dealing with redis items as string, casting to string
-
3
user_ids += account.account_users.where(auto_offline: false)&.map(&:user_id)&.map(&:to_s)
-
3
user_ids.uniq
-
end
-
end
-
# refer : https://redis.io/commands
-
-
1
module Redis::Alfred
-
1
include Redis::RedisKeys
-
-
1
class << self
-
# key operations
-
-
# set a value in redis
-
1
def set(key, value, nx: false, ex: false) # rubocop:disable Naming/MethodParameterName
-
$alfred.with { |conn| conn.set(key, value, nx: nx, ex: ex) }
-
end
-
-
# set a key with expiry period
-
# TODO: Deprecate this method, use set with ex: 1.day instead
-
1
def setex(key, value, expiry = 1.day)
-
24
$alfred.with { |conn| conn.setex(key, expiry, value) }
-
end
-
-
1
def get(key)
-
60
$alfred.with { |conn| conn.get(key) }
-
end
-
-
1
def delete(key)
-
$alfred.with { |conn| conn.del(key) }
-
end
-
-
# increment a key by 1. throws error if key value is incompatible
-
# sets key to 0 before operation if key doesn't exist
-
1
def incr(key)
-
$alfred.with { |conn| conn.incr(key) }
-
end
-
-
1
def exists?(key)
-
$alfred.with { |conn| conn.exists?(key) }
-
end
-
-
# list operations
-
-
1
def llen(key)
-
$alfred.with { |conn| conn.llen(key) }
-
end
-
-
1
def lrange(key, start_index = 0, end_index = -1)
-
6
$alfred.with { |conn| conn.lrange(key, start_index, end_index) }
-
end
-
-
1
def rpop(key)
-
$alfred.with { |conn| conn.rpop(key) }
-
end
-
-
1
def lpush(key, values)
-
$alfred.with { |conn| conn.lpush(key, values) }
-
end
-
-
1
def rpoplpush(source, destination)
-
$alfred.with { |conn| conn.rpoplpush(source, destination) }
-
end
-
-
1
def lrem(key, value, count = 0)
-
$alfred.with { |conn| conn.lrem(key, count, value) }
-
end
-
-
# hash operations
-
-
# add a key value to redis hash
-
1
def hset(key, field, value)
-
$alfred.with { |conn| conn.hset(key, field, value) }
-
end
-
-
# get value from redis hash
-
1
def hget(key, field)
-
$alfred.with { |conn| conn.hget(key, field) }
-
end
-
-
# get values of multiple keys from redis hash
-
1
def hmget(key, fields)
-
$alfred.with { |conn| conn.hmget(key, *fields) }
-
end
-
-
# sorted set operations
-
-
# add score and value for a key
-
1
def zadd(key, score, value)
-
$alfred.with { |conn| conn.zadd(key, score, value) }
-
end
-
-
# get score of a value for key
-
1
def zscore(key, value)
-
$alfred.with { |conn| conn.zscore(key, value) }
-
end
-
-
# get values by score
-
1
def zrangebyscore(key, range_start, range_end)
-
6
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) }
-
end
-
-
# remove values by score
-
# exclusive score is specified by prefixing (
-
1
def zremrangebyscore(key, range_start, range_end)
-
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
-
end
-
end
-
end
-
1
module Redis::Config
-
1
DEFAULT_SENTINEL_PORT ||= '26379'.freeze
-
1
class << self
-
1
def app
-
1
config
-
end
-
-
1
def config
-
1
@config ||= sentinel? ? sentinel_config : base_config
-
end
-
-
1
def base_config
-
{
-
1
url: ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379'),
-
password: ENV.fetch('REDIS_PASSWORD', nil).presence,
-
ssl_params: { verify_mode: Chatwoot.redis_ssl_verify_mode },
-
reconnect_attempts: 2,
-
timeout: 1
-
}
-
end
-
-
1
def sentinel?
-
1
ENV.fetch('REDIS_SENTINELS', nil).presence
-
end
-
-
1
def sentinel_url_config(sentinel_url)
-
host, port = sentinel_url.split(':').map(&:strip)
-
sentinel_url_config = { host: host, port: port || DEFAULT_SENTINEL_PORT }
-
password = ENV.fetch('REDIS_SENTINEL_PASSWORD', base_config[:password])
-
sentinel_url_config[:password] = password if password.present?
-
sentinel_url_config
-
end
-
-
1
def sentinel_config
-
redis_sentinels = ENV.fetch('REDIS_SENTINELS', nil)
-
-
# expected format for REDIS_SENTINELS url string is host1:port1, host2:port2
-
sentinels = redis_sentinels.split(',').map do |sentinel_url|
-
sentinel_url_config(sentinel_url)
-
end
-
-
# over-write redis url as redis://:<your-redis-password>@<master-name>/ when using sentinel
-
# more at https://github.com/redis/redis-rb/issues/531#issuecomment-263501322
-
master = "redis://#{ENV.fetch('REDIS_SENTINEL_MASTER_NAME', 'mymaster')}"
-
-
base_config.merge({ url: master, sentinels: sentinels })
-
end
-
end
-
end
-
# Redis::LockManager provides a simple mechanism to handle distributed locks using Redis.
-
# This class ensures that only one instance of a given operation runs at a given time across all processes/nodes.
-
# It uses the $alfred Redis namespace for all its operations.
-
#
-
# Example Usage:
-
#
-
# lock_manager = Redis::LockManager.new
-
#
-
# if lock_manager.lock("some_key")
-
# # Critical code that should not be run concurrently
-
# lock_manager.unlock("some_key")
-
# end
-
#
-
class Redis::LockManager
-
# Default lock timeout set to 1 second. This means that if the lock isn't released
-
# within 1 second, it will automatically expire.
-
# This helps to avoid deadlocks in case the process holding the lock crashes or fails to release it.
-
LOCK_TIMEOUT = 1.second
-
-
# Attempts to acquire a lock for the given key.
-
#
-
# If the lock is successfully acquired, the method returns true. If the key is
-
# already locked or if any other error occurs, it returns false.
-
#
-
# === Parameters
-
# * +key+ - The key for which the lock is to be acquired.
-
# * +timeout+ - Duration in seconds for which the lock is valid. Defaults to +LOCK_TIMEOUT+.
-
#
-
# === Returns
-
# * +true+ if the lock was successfully acquired.
-
# * +false+ if the lock was not acquired.
-
def lock(key, timeout = LOCK_TIMEOUT)
-
value = Time.now.to_f.to_s
-
# nx: true means set the key only if it does not exist
-
Redis::Alfred.set(key, value, nx: true, ex: timeout) ? true : false
-
end
-
-
# Releases a lock for the given key.
-
#
-
# === Parameters
-
# * +key+ - The key for which the lock is to be released.
-
#
-
# === Returns
-
# * +true+ indicating the lock release operation was initiated.
-
#
-
# Note: If the key wasn't locked, this operation will have no effect.
-
def unlock(key)
-
Redis::Alfred.delete(key)
-
true
-
end
-
-
# Checks if the given key is currently locked.
-
#
-
# === Parameters
-
# * +key+ - The key to check.
-
#
-
# === Returns
-
# * +true+ if the key is locked.
-
# * +false+ otherwise.
-
def locked?(key)
-
Redis::Alfred.exists?(key)
-
end
-
end
-
1
module Redis::RedisKeys
-
## Inbox Keys
-
# Array storing the ordered ids for agent round robin assignment
-
1
ROUND_ROBIN_AGENTS = 'ROUND_ROBIN_AGENTS:%<inbox_id>d'.freeze
-
-
## Conversation keys
-
# Detect whether to send an email reply to the conversation
-
1
CONVERSATION_MAILER_KEY = 'CONVERSATION::%<conversation_id>d'.freeze
-
# Whether a conversation is muted ?
-
1
CONVERSATION_MUTE_KEY = 'CONVERSATION::%<id>d::MUTED'.freeze
-
1
CONVERSATION_DRAFT_MESSAGE = 'CONVERSATION::%<id>d::DRAFT_MESSAGE'.freeze
-
-
## User Keys
-
# SSO Auth Tokens
-
1
USER_SSO_AUTH_TOKEN = 'USER_SSO_AUTH_TOKEN::%<user_id>d::%<token>s'.freeze
-
-
## Online Status Keys
-
# hash containing user_id key and status as value
-
1
ONLINE_STATUS = 'ONLINE_STATUS::%<account_id>d'.freeze
-
# sorted set storing online presense of account contacts
-
1
ONLINE_PRESENCE_CONTACTS = 'ONLINE_PRESENCE::%<account_id>d::CONTACTS'.freeze
-
# sorted set storing online presense of account users
-
1
ONLINE_PRESENCE_USERS = 'ONLINE_PRESENCE::%<account_id>d::USERS'.freeze
-
-
## Authorization Status Keys
-
# Used to track token expiry and such issues for facebook slack integrations etc
-
1
AUTHORIZATION_ERROR_COUNT = 'AUTHORIZATION_ERROR_COUNT:%<obj_type>s:%<obj_id>d'.freeze
-
1
REAUTHORIZATION_REQUIRED = 'REAUTHORIZATION_REQUIRED:%<obj_type>s:%<obj_id>d'.freeze
-
-
## Internal Installation related keys
-
1
CHATWOOT_INSTALLATION_ONBOARDING = 'CHATWOOT_INSTALLATION_ONBOARDING'.freeze
-
1
CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING = 'CHATWOOT_CONFIG_RESET_WARNING'.freeze
-
1
LATEST_CHATWOOT_VERSION = 'LATEST_CHATWOOT_VERSION'.freeze
-
# Check if a message create with same source-id is in progress?
-
1
MESSAGE_SOURCE_KEY = 'MESSAGE_SOURCE_KEY::%<id>s'.freeze
-
1
OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::V1::%<event_name>s::%<conversation_id>d::%<updated_at>d'.freeze
-
-
## Sempahores / Locks
-
# We don't want to process messages from the same sender concurrently to prevent creating double conversations
-
1
FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze
-
1
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
-
1
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
-
1
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
-
1
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
-
end
-
1
module RegexHelper
-
# user https://rubular.com/ to quickly validate your regex
-
-
# the following regext needs atleast one character which should be
-
# valid unicode letter, unicode number, underscore, hyphen
-
# shouldn't start with a underscore or hyphen
-
1
UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE = Regexp.new('\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z')
-
1
MENTION_REGEX = Regexp.new('\[(@[\w_. ]+)\]\(mention://(?:user|team)/\d+/(.*?)+\)')
-
-
1
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
-
1
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
-
1
WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,15}\z')
-
end
-
## Class to generate sample data for a chatwoot test @Account.
-
############################################################
-
### Usage #####
-
#
-
# # Seed an account with all data types in this class
-
# Seeders::AccountSeeder.new(account: Account.find(1)).perform!
-
#
-
#
-
############################################################
-
-
class Seeders::AccountSeeder
-
def initialize(account:)
-
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
-
-
@account_data = ActiveSupport::HashWithIndifferentAccess.new(YAML.safe_load(Rails.root.join('lib/seeders/seed_data.yml').read))
-
@account = account
-
end
-
-
def perform!
-
set_up_account
-
seed_teams
-
set_up_users
-
seed_labels
-
seed_canned_responses
-
seed_inboxes
-
seed_contacts
-
end
-
-
def set_up_account
-
@account.teams.destroy_all
-
@account.conversations.destroy_all
-
@account.labels.destroy_all
-
@account.inboxes.destroy_all
-
@account.contacts.destroy_all
-
end
-
-
def seed_teams
-
@account_data['teams'].each do |team_name|
-
@account.teams.create!(name: team_name)
-
end
-
end
-
-
def seed_labels
-
@account_data['labels'].each do |label|
-
@account.labels.create!(label)
-
end
-
end
-
-
def set_up_users
-
@account_data['users'].each do |user|
-
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: (user['email']).to_s)
-
user_record.skip_confirmation!
-
user_record.save!
-
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
-
AccountUser.create_with(role: (user['role'] || 'agent')).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
-
next if user['team'].blank?
-
-
add_user_to_teams(user: user_record, teams: user['team'])
-
end
-
end
-
-
def add_user_to_teams(user:, teams:)
-
teams.each do |team|
-
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
-
TeamMember.find_or_create_by!(team_id: team_record.id, user_id: user.id) unless team_record.nil?
-
end
-
end
-
-
def seed_canned_responses(count: 50)
-
count.times do
-
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
-
end
-
end
-
-
def seed_contacts
-
@account_data['contacts'].each do |contact_data|
-
contact = @account.contacts.find_or_initialize_by(email: contact_data['email'])
-
if contact.new_record?
-
contact.update!(contact_data.slice('name', 'email'))
-
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
-
end
-
contact_data['conversations'].each do |conversation_data|
-
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
-
contact_inbox = inbox.contact_inboxes.create_or_find_by!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
-
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
-
end
-
end
-
end
-
-
def create_conversation(contact_inbox:, conversation_data:)
-
assignee = User.from_email(conversation_data['assignee']) if conversation_data['assignee'].present?
-
conversation = contact_inbox.conversations.create!(account: contact_inbox.inbox.account, contact: contact_inbox.contact,
-
inbox: contact_inbox.inbox, assignee: assignee)
-
create_messages(conversation: conversation, messages: conversation_data['messages'])
-
conversation.update_labels(conversation_data[:labels]) if conversation_data[:labels].present?
-
conversation.update!(priority: conversation_data[:priority]) if conversation_data[:priority].present?
-
end
-
-
def create_messages(conversation:, messages:)
-
messages.each do |message_data|
-
sender = find_message_sender(conversation, message_data)
-
conversation.messages.create!(
-
message_data.slice('content', 'message_type').merge(
-
account: conversation.inbox.account, sender: sender, inbox: conversation.inbox
-
)
-
)
-
end
-
end
-
-
def find_message_sender(conversation, message_data)
-
if message_data['message_type'] == 'incoming'
-
conversation.contact
-
elsif message_data['sender'].present?
-
User.from_email(message_data['sender'])
-
end
-
end
-
-
def seed_inboxes
-
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
-
end
-
end
-
## Class to generate sample inboxes for a chatwoot test @Account.
-
############################################################
-
### Usage #####
-
#
-
# # Seed an account with all data types in this class
-
# Seeders::InboxSeeder.new(account: @Account.find(1), company_data: {name: 'PaperLayer', doamin: 'paperlayer.test'}).perform!
-
#
-
#
-
############################################################
-
-
class Seeders::InboxSeeder
-
def initialize(account:, company_data:)
-
raise 'Inbox Seeding is not allowed in production.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
-
-
@account = account
-
@company_data = company_data
-
end
-
-
def perform!
-
seed_website_inbox
-
seed_facebook_inbox
-
seed_twitter_inbox
-
seed_whatsapp_inbox
-
seed_sms_inbox
-
seed_email_inbox
-
seed_api_inbox
-
seed_telegram_inbox
-
seed_line_inbox
-
end
-
-
def seed_website_inbox
-
channel = Channel::WebWidget.create!(account: @account, website_url: "https://#{@company_data['domain']}")
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Website")
-
end
-
-
def seed_facebook_inbox
-
channel = Channel::FacebookPage.create!(account: @account, user_access_token: SecureRandom.hex, page_access_token: SecureRandom.hex,
-
page_id: SecureRandom.hex)
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Facebook")
-
end
-
-
def seed_twitter_inbox
-
channel = Channel::TwitterProfile.create!(account: @account, twitter_access_token: SecureRandom.hex,
-
twitter_access_token_secret: SecureRandom.hex, profile_id: '123')
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Twitter")
-
end
-
-
def seed_whatsapp_inbox
-
# rubocop:disable Rails/SkipsModelValidations
-
Channel::Whatsapp.insert(
-
{
-
account_id: @account.id,
-
phone_number: Faker::PhoneNumber.cell_phone_in_e164,
-
created_at: Time.now.utc,
-
updated_at: Time.now.utc
-
},
-
returning: %w[id]
-
)
-
# rubocop:enable Rails/SkipsModelValidations
-
-
channel = Channel::Whatsapp.find_by(account_id: @account.id)
-
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Whatsapp")
-
end
-
-
def seed_sms_inbox
-
channel = Channel::Sms.create!(account: @account, phone_number: Faker::PhoneNumber.cell_phone_in_e164)
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Mobile")
-
end
-
-
def seed_email_inbox
-
channel = Channel::Email.create!(account: @account, email: "test#{SecureRandom.hex}@#{@company_data['domain']}",
-
forward_to_email: "test_fwd#{SecureRandom.hex}@#{@company_data['domain']}")
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Email")
-
end
-
-
def seed_api_inbox
-
channel = Channel::Api.create!(account: @account)
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} API")
-
end
-
-
def seed_telegram_inbox
-
# rubocop:disable Rails/SkipsModelValidations
-
bot_token = SecureRandom.hex
-
Channel::Telegram.insert(
-
{
-
account_id: @account.id,
-
bot_name: (@company_data['name']).to_s,
-
bot_token: bot_token,
-
created_at: Time.now.utc,
-
updated_at: Time.now.utc
-
},
-
returning: %w[id]
-
)
-
channel = Channel::Telegram.find_by(bot_token: bot_token)
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Telegram")
-
# rubocop:enable Rails/SkipsModelValidations
-
end
-
-
def seed_line_inbox
-
channel = Channel::Line.create!(account: @account, line_channel_id: SecureRandom.hex, line_channel_secret: SecureRandom.hex,
-
line_channel_token: SecureRandom.hex)
-
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Line")
-
end
-
end
-
module Seeders::MessageSeeder
-
def self.create_sample_email_collect_message(conversation)
-
Message.create!(
-
account: conversation.account,
-
inbox: conversation.inbox,
-
conversation: conversation,
-
message_type: :template,
-
content_type: :input_email,
-
content: 'Get notified by email'
-
)
-
end
-
-
def self.create_sample_csat_collect_message(conversation)
-
Message.create!(
-
account: conversation.account,
-
inbox: conversation.inbox,
-
conversation: conversation,
-
message_type: :template,
-
content_type: :input_csat,
-
content: 'Please rate the support'
-
)
-
end
-
-
def self.create_sample_cards_message(conversation)
-
Message.create!(
-
account: conversation.account,
-
inbox: conversation.inbox,
-
conversation: conversation,
-
message_type: :template,
-
content_type: 'cards',
-
content: 'cards',
-
content_attributes: {
-
items: [
-
sample_card_item,
-
sample_card_item
-
]
-
}
-
)
-
end
-
-
def self.sample_card_item
-
{
-
media_url: 'https://i.imgur.com/d8Djr4k.jpg',
-
title: 'Acme Shoes 2.0',
-
description: 'Move with Acme Shoe 2.0',
-
actions: [
-
{
-
type: 'link',
-
text: 'View More',
-
uri: 'http://acme-shoes.inc'
-
},
-
{
-
type: 'postback',
-
text: 'Add to cart',
-
payload: 'ITEM_SELECTED'
-
}
-
]
-
}
-
end
-
-
def self.create_sample_input_select_message(conversation)
-
Message.create!(
-
account: conversation.account,
-
inbox: conversation.inbox,
-
conversation: conversation,
-
message_type: :template,
-
content: 'Your favorite food',
-
content_type: 'input_select',
-
content_attributes: {
-
items: [
-
{ title: '🌯 Burito', value: 'Burito' },
-
{ title: '🍝 Pasta', value: 'Pasta' },
-
{ title: ' 🍱 Sushi', value: 'Sushi' },
-
{ title: ' 🥗 Salad', value: 'Salad' }
-
]
-
}
-
)
-
end
-
-
def self.create_sample_form_message(conversation)
-
Message.create!(
-
account: conversation.account,
-
inbox: conversation.inbox,
-
conversation: conversation,
-
message_type: :template,
-
content_type: 'form',
-
content: 'form',
-
content_attributes: sample_form
-
)
-
end
-
-
def self.sample_form
-
{
-
items: [
-
{ name: 'email', placeholder: 'Please enter your email', type: 'email', label: 'Email', required: 'required',
-
pattern_error: 'Please fill this field', pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$' },
-
{ name: 'text_area', placeholder: 'Please enter text', type: 'text_area', label: 'Large Text', required: 'required',
-
pattern_error: 'Please fill this field' },
-
{ name: 'text', placeholder: 'Please enter text', type: 'text', label: 'text', default: 'defaut value', required: 'required',
-
pattern: '^[a-zA-Z ]*$', pattern_error: 'Only alphabets are allowed' },
-
{ name: 'select', label: 'Select Option', type: 'select', options: [{ label: '🌯 Burito', value: 'Burito' },
-
{ label: '🍝 Pasta', value: 'Pasta' }] }
-
]
-
}
-
end
-
-
def self.create_sample_articles_message(conversation)
-
Message.create!(
-
account: conversation.account,
-
inbox: conversation.inbox,
-
conversation: conversation,
-
message_type: :template,
-
content: 'Tech Companies',
-
content_type: 'article',
-
content_attributes: {
-
items: [
-
{ title: 'Acme Hardware', description: 'Hardware reimagined', link: 'http://acme-hardware.inc' },
-
{ title: 'Acme Search', description: 'The best Search Engine', link: 'http://acme-search.inc' }
-
]
-
}
-
)
-
end
-
end
-
module TestData
-
def self.generate
-
Orchestrator.call
-
end
-
-
def self.cleanup
-
CleanupService.call
-
end
-
end
-
-
require_relative 'test_data/constants'
-
require_relative 'test_data/database_optimizer'
-
require_relative 'test_data/cleanup_service'
-
require_relative 'test_data/account_creator'
-
require_relative 'test_data/inbox_creator'
-
require_relative 'test_data/display_id_tracker'
-
require_relative 'test_data/contact_batch_service'
-
require_relative 'test_data/orchestrator'
-
class TestData::AccountCreator
-
DATA_FILE = 'tmp/test_data_account_ids.txt'.freeze
-
-
def self.create!(id)
-
company_name = generate_company_name
-
domain = generate_domain(company_name)
-
account = Account.create!(
-
id: id,
-
name: company_name,
-
domain: domain,
-
created_at: Faker::Time.between(from: 2.years.ago, to: 6.months.ago)
-
)
-
persist_account_id(account.id)
-
account
-
end
-
-
def self.generate_company_name
-
"#{Faker::Company.name} #{TestData::Constants::COMPANY_TYPES.sample}"
-
end
-
-
def self.generate_domain(company_name)
-
"#{company_name.parameterize}.#{TestData::Constants::DOMAIN_EXTENSIONS.sample}"
-
end
-
-
def self.persist_account_id(account_id)
-
FileUtils.mkdir_p('tmp')
-
File.open(DATA_FILE, 'a') do |file|
-
file.write("#{account_id},")
-
end
-
end
-
end
-
class TestData::CleanupService
-
DATA_FILE = 'tmp/test_data_account_ids.txt'.freeze
-
-
class << self
-
def call
-
Rails.logger.info 'Cleaning up any existing test data...'
-
-
return log_no_file_found unless file_exists?
-
-
account_ids = parse_account_ids_from_file
-
-
if account_ids.any?
-
delete_accounts(account_ids)
-
else
-
log_no_accounts_found
-
end
-
-
delete_data_file
-
Rails.logger.info '==> Cleanup complete!'
-
end
-
-
private
-
-
def file_exists?
-
File.exist?(DATA_FILE)
-
end
-
-
def log_no_file_found
-
Rails.logger.info 'No test data file found, skipping cleanup'
-
end
-
-
def parse_account_ids_from_file
-
File.read(DATA_FILE).split(',').map(&:strip).reject(&:empty?).map(&:to_i)
-
end
-
-
def delete_accounts(account_ids)
-
Rails.logger.info "Found #{account_ids.size} test accounts to clean up: #{account_ids.join(', ')}"
-
start_time = Time.zone.now
-
Account.where(id: account_ids).destroy_all
-
Rails.logger.info "Deleted #{account_ids.size} accounts in #{Time.zone.now - start_time}s"
-
end
-
-
def log_no_accounts_found
-
Rails.logger.info 'No test account IDs found in the data file'
-
end
-
-
def delete_data_file
-
File.delete(DATA_FILE)
-
end
-
end
-
end
-
module TestData::Constants
-
NUM_ACCOUNTS = 20
-
MIN_MESSAGES = 1_000_000 # 1M
-
MAX_MESSAGES = 10_000_000 # 10M
-
BATCH_SIZE = 5_000
-
-
MAX_CONVERSATIONS_PER_CONTACT = 20
-
INBOXES_PER_ACCOUNT = 5
-
STATUSES = %w[open resolved pending].freeze
-
MESSAGE_TYPES = %w[incoming outgoing].freeze
-
-
MIN_MESSAGES_PER_CONVO = 5
-
MAX_MESSAGES_PER_CONVO = 50
-
-
COMPANY_TYPES = %w[Retail Healthcare Finance Education Manufacturing].freeze
-
DOMAIN_EXTENSIONS = %w[com io tech ai].freeze
-
COUNTRY_CODES = %w[1 44 91 61 81 86 49 33 34 39].freeze # US, UK, India, Australia, Japan, China, Germany, France, Spain, Italy
-
end
-
class TestData::ContactBatchService
-
def initialize(account:, inboxes:, batch_size:, display_id_tracker:)
-
@account = account
-
@inboxes = inboxes
-
@batch_size = batch_size
-
@display_id_tracker = display_id_tracker
-
@total_messages = 0
-
end
-
-
# Generates contacts, contact_inboxes, conversations, and messages
-
# Returns the total number of messages created in this batch
-
def generate!
-
Rails.logger.info { "Starting batch generation for account ##{@account.id} with #{@batch_size} contacts" }
-
-
create_contacts
-
create_contact_inboxes
-
create_conversations
-
create_messages
-
-
Rails.logger.info { "Completed batch with #{@total_messages} messages for account ##{@account.id}" }
-
@total_messages
-
end
-
-
private
-
-
# rubocop:disable Rails/SkipsModelValidations
-
def create_contacts
-
Rails.logger.info { "Creating #{@batch_size} contacts for account ##{@account.id}" }
-
start_time = Time.current
-
-
@contacts_data = Array.new(@batch_size) { build_contact_data }
-
Contact.insert_all!(@contacts_data) if @contacts_data.any?
-
@contacts = Contact
-
.where(account_id: @account.id)
-
.order(created_at: :desc)
-
.limit(@batch_size)
-
-
Rails.logger.info { "Contacts created in #{Time.current - start_time}s" }
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
-
def build_contact_data
-
created_at = Faker::Time.between(from: 1.year.ago, to: Time.current)
-
{
-
account_id: @account.id,
-
name: Faker::Name.name,
-
email: "#{SecureRandom.uuid}@example.com",
-
phone_number: generate_e164_phone_number,
-
additional_attributes: maybe_add_additional_attributes,
-
created_at: created_at,
-
updated_at: created_at
-
}
-
end
-
-
def maybe_add_additional_attributes
-
return unless rand < 0.3
-
-
{
-
company: Faker::Company.name,
-
city: Faker::Address.city,
-
country: Faker::Address.country_code
-
}
-
end
-
-
def generate_e164_phone_number
-
return nil unless rand < 0.7
-
-
country_code = TestData::Constants::COUNTRY_CODES.sample
-
subscriber_number = rand(1_000_000..9_999_999_999).to_s
-
subscriber_number = subscriber_number[0...(15 - country_code.length)]
-
"+#{country_code}#{subscriber_number}"
-
end
-
-
# rubocop:disable Rails/SkipsModelValidations
-
def create_contact_inboxes
-
Rails.logger.info { "Creating contact inboxes for #{@contacts.size} contacts" }
-
start_time = Time.current
-
-
contact_inboxes_data = @contacts.flat_map do |contact|
-
@inboxes.map do |inbox|
-
{
-
inbox_id: inbox.id,
-
contact_id: contact.id,
-
source_id: SecureRandom.uuid,
-
created_at: contact.created_at,
-
updated_at: contact.created_at
-
}
-
end
-
end
-
-
count = contact_inboxes_data.size
-
ContactInbox.insert_all!(contact_inboxes_data) if contact_inboxes_data.any?
-
@contact_inboxes = ContactInbox.where(contact_id: @contacts.pluck(:id))
-
-
Rails.logger.info { "Created #{count} contact inboxes in #{Time.current - start_time}s" }
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
-
# rubocop:disable Rails/SkipsModelValidations
-
def create_conversations
-
Rails.logger.info { "Creating conversations for account ##{@account.id}" }
-
start_time = Time.current
-
-
conversations_data = []
-
@contact_inboxes.each do |ci|
-
num_convos = rand(1..TestData::Constants::MAX_CONVERSATIONS_PER_CONTACT)
-
num_convos.times { conversations_data << build_conversation(ci) }
-
end
-
-
count = conversations_data.size
-
Rails.logger.info { "Preparing to insert #{count} conversations" }
-
-
Conversation.insert_all!(conversations_data) if conversations_data.any?
-
@conversations = Conversation.where(
-
account_id: @account.id,
-
display_id: conversations_data.pluck(:display_id)
-
).order(:created_at)
-
-
Rails.logger.info { "Created #{count} conversations in #{Time.current - start_time}s" }
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
-
def build_conversation(contact_inbox)
-
created_at = Faker::Time.between(from: contact_inbox.created_at, to: Time.current)
-
{
-
account_id: @account.id,
-
inbox_id: contact_inbox.inbox_id,
-
contact_id: contact_inbox.contact_id,
-
contact_inbox_id: contact_inbox.id,
-
status: TestData::Constants::STATUSES.sample,
-
created_at: created_at,
-
updated_at: created_at,
-
display_id: @display_id_tracker.next_id
-
}
-
end
-
-
# rubocop:disable Rails/SkipsModelValidations
-
def create_messages
-
Rails.logger.info { "Creating messages for #{@conversations.size} conversations" }
-
start_time = Time.current
-
-
batch_count = 0
-
@conversations.find_in_batches(batch_size: 1000) do |batch|
-
batch_count += 1
-
batch_start = Time.current
-
-
messages_data = batch.flat_map do |convo|
-
build_messages_for_conversation(convo)
-
end
-
-
batch_message_count = messages_data.size
-
Rails.logger.info { "Preparing to insert #{batch_message_count} messages (batch #{batch_count})" }
-
-
Message.insert_all!(messages_data) if messages_data.any?
-
@total_messages += batch_message_count
-
-
Rails.logger.info { "Created batch #{batch_count} with #{batch_message_count} messages in #{Time.current - batch_start}s" }
-
end
-
-
Rails.logger.info { "Created total of #{@total_messages} messages in #{Time.current - start_time}s" }
-
end
-
# rubocop:enable Rails/SkipsModelValidations
-
-
def build_messages_for_conversation(conversation)
-
num_messages = rand(TestData::Constants::MIN_MESSAGES_PER_CONVO..TestData::Constants::MAX_MESSAGES_PER_CONVO)
-
message_type = TestData::Constants::MESSAGE_TYPES.sample
-
time_range = [conversation.created_at, Time.current]
-
generate_messages(conversation, num_messages, message_type, time_range)
-
end
-
-
def generate_messages(conversation, num_messages, initial_message_type, time_range)
-
message_type = initial_message_type
-
-
Array.new(num_messages) do
-
message_type = (message_type == 'incoming' ? 'outgoing' : 'incoming')
-
created_at = Faker::Time.between(from: time_range.first, to: time_range.last)
-
build_message_data(conversation, message_type, created_at)
-
end
-
end
-
-
def build_message_data(conversation, message_type, created_at)
-
{
-
account_id: @account.id,
-
inbox_id: conversation.inbox_id,
-
conversation_id: conversation.id,
-
message_type: message_type,
-
content: Faker::Lorem.paragraph(sentence_count: 2),
-
created_at: created_at,
-
updated_at: created_at,
-
private: false,
-
status: 'sent',
-
content_type: 'text',
-
source_id: SecureRandom.uuid
-
}
-
end
-
end
-
class TestData::DatabaseOptimizer
-
class << self
-
# Tables that need trigger management
-
TABLES_WITH_TRIGGERS = %w[conversations messages].freeze
-
-
# Memory settings in MB
-
# Increased work_mem for better query performance with complex operations
-
WORK_MEM = 256
-
-
def setup
-
Rails.logger.info '==> Setting up database optimizations for improved performance'
-
-
# Remove statement timeout to allow long-running operations to complete
-
Rails.logger.info ' Removing statement timeout'
-
ActiveRecord::Base.connection.execute('SET statement_timeout = 0')
-
-
# Increase working memory for better query performance
-
Rails.logger.info " Increasing work_mem to #{WORK_MEM}MB"
-
ActiveRecord::Base.connection.execute("SET work_mem = '#{WORK_MEM}MB'")
-
-
# Set tables to UNLOGGED mode for better write performance
-
# This disables WAL completely for these tables
-
Rails.logger.info ' Setting tables to UNLOGGED mode'
-
set_tables_unlogged
-
-
# Disable triggers on specified tables to avoid overhead
-
Rails.logger.info ' Disabling triggers on specified tables'
-
disable_triggers
-
-
Rails.logger.info '==> Database optimizations complete, data generation will run faster'
-
end
-
-
def restore
-
Rails.logger.info '==> Restoring database settings to normal'
-
-
Rails.logger.info ' Re-enabling triggers on specified tables'
-
enable_triggers
-
-
Rails.logger.info ' Setting tables back to LOGGED mode'
-
set_tables_logged
-
-
# Reset memory settings to defaults
-
Rails.logger.info ' Resetting memory settings to defaults'
-
ActiveRecord::Base.connection.execute('RESET work_mem')
-
ActiveRecord::Base.connection.execute('RESET maintenance_work_mem')
-
-
Rails.logger.info '==> Database settings restored to normal operation'
-
end
-
-
private
-
-
def disable_triggers
-
TABLES_WITH_TRIGGERS.each do |table|
-
Rails.logger.info " Disabling triggers on #{table} table"
-
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} DISABLE TRIGGER ALL")
-
end
-
end
-
-
def enable_triggers
-
TABLES_WITH_TRIGGERS.each do |table|
-
Rails.logger.info " Enabling triggers on #{table} table"
-
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} ENABLE TRIGGER ALL")
-
end
-
end
-
-
def set_tables_unlogged
-
TABLES_WITH_TRIGGERS.each do |table|
-
Rails.logger.info " Setting #{table} table as UNLOGGED"
-
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} SET UNLOGGED")
-
end
-
end
-
-
def set_tables_logged
-
TABLES_WITH_TRIGGERS.each do |table|
-
Rails.logger.info " Setting #{table} table as LOGGED"
-
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} SET LOGGED")
-
end
-
end
-
end
-
end
-
class TestData::DisplayIdTracker
-
attr_reader :current
-
-
def initialize(account:)
-
max_display_id = Conversation.where(account_id: account.id).maximum(:display_id) || 0
-
@current = max_display_id
-
end
-
-
def next_id
-
@current += 1
-
end
-
end
-
class TestData::InboxCreator
-
def self.create_for(account)
-
Array.new(TestData::Constants::INBOXES_PER_ACCOUNT) do
-
channel = Channel::Api.create!(account: account)
-
Inbox.create!(
-
account_id: account.id,
-
name: "API Inbox #{SecureRandom.hex(4)}",
-
channel: channel
-
)
-
end
-
end
-
end
-
class TestData::Orchestrator
-
class << self
-
def call
-
Rails.logger.info { '========== STARTING TEST DATA GENERATION ==========' }
-
-
cleanup_existing_data
-
set_start_id
-
-
Rails.logger.info { "Starting to generate distributed test data across #{TestData::Constants::NUM_ACCOUNTS} accounts..." }
-
Rails.logger.info do
-
"Each account have between #{TestData::Constants::MIN_MESSAGES / 1_000_000}M and #{TestData::Constants::MAX_MESSAGES / 1_000_000}M messages"
-
end
-
-
TestData::Constants::NUM_ACCOUNTS.times do |account_index|
-
Rails.logger.info { "Processing account #{account_index + 1} of #{TestData::Constants::NUM_ACCOUNTS}" }
-
process_account(account_index)
-
end
-
-
Rails.logger.info { "========== ALL DONE! Created #{TestData::Constants::NUM_ACCOUNTS} accounts with distributed test data ==========" }
-
end
-
-
private
-
-
# Simple value object to group generation parameters
-
class DataGenerationParams
-
attr_reader :account, :inboxes, :total_contacts_needed, :target_message_count, :display_id_tracker
-
-
def initialize(account:, inboxes:, total_contacts_needed:, target_message_count:, display_id_tracker:)
-
@account = account
-
@inboxes = inboxes
-
@total_contacts_needed = total_contacts_needed
-
@target_message_count = target_message_count
-
@display_id_tracker = display_id_tracker
-
end
-
end
-
-
# 1. Remove existing data for old test accounts
-
def cleanup_existing_data
-
Rails.logger.info { 'Cleaning up existing test data...' }
-
TestData::CleanupService.call
-
Rails.logger.info { 'Cleanup complete' }
-
end
-
-
# 2. Find the max Account ID to avoid conflicts
-
def set_start_id
-
max_id = Account.maximum(:id) || 0
-
@start_id = max_id + 1
-
Rails.logger.info { "Setting start ID to #{@start_id}" }
-
end
-
-
# 3. Create an account, its inboxes, and some data
-
def process_account(account_index)
-
account_id = @start_id + account_index
-
Rails.logger.info { "Creating account with ID #{account_id}" }
-
account = TestData::AccountCreator.create!(account_id)
-
-
inboxes = TestData::InboxCreator.create_for(account)
-
target_messages = rand(TestData::Constants::MIN_MESSAGES..TestData::Constants::MAX_MESSAGES)
-
avg_per_convo = rand(15..50)
-
total_convos = (target_messages / avg_per_convo.to_f).ceil
-
total_contacts = (total_convos / TestData::Constants::MAX_CONVERSATIONS_PER_CONTACT.to_f).ceil
-
-
log_account_details(account, target_messages, total_contacts, total_convos)
-
display_id_tracker = TestData::DisplayIdTracker.new(account: account)
-
-
params = DataGenerationParams.new(
-
account: account,
-
inboxes: inboxes,
-
total_contacts_needed: total_contacts,
-
target_message_count: target_messages,
-
display_id_tracker: display_id_tracker
-
)
-
-
Rails.logger.info { "Starting data generation for account ##{account.id}" }
-
generate_data_for_account(params)
-
end
-
-
def generate_data_for_account(params)
-
contact_count = 0
-
message_count = 0
-
batch_number = 0
-
-
while contact_count < params.total_contacts_needed
-
batch_number += 1
-
batch_size = [TestData::Constants::BATCH_SIZE, params.total_contacts_needed - contact_count].min
-
Rails.logger.info { "Processing batch ##{batch_number} (#{batch_size} contacts) for account ##{params.account.id}" }
-
-
batch_service = TestData::ContactBatchService.new(
-
account: params.account,
-
inboxes: params.inboxes,
-
batch_size: batch_size,
-
display_id_tracker: params.display_id_tracker
-
)
-
batch_created_messages = batch_service.generate!
-
-
contact_count += batch_size
-
message_count += batch_created_messages
-
-
end
-
-
Rails.logger.info { "==> Completed Account ##{params.account.id} with #{message_count} messages" }
-
end
-
-
def log_account_details(account, target_messages, total_contacts, total_convos)
-
Rails.logger.info { "==> Account ##{account.id} plan: target of #{target_messages / 1_000_000.0}M messages" }
-
Rails.logger.info { " Planning for #{total_contacts} contacts and #{total_convos} conversations" }
-
end
-
end
-
end
-
1
require 'uri'
-
1
module UrlHelper
-
1
def url_valid?(url)
-
url = begin
-
URI.parse(url)
-
rescue StandardError
-
false
-
end
-
url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS)
-
end
-
end
-
class VapidService
-
def self.public_key
-
vapid_keys['public_key']
-
end
-
-
def self.private_key
-
vapid_keys['private_key']
-
end
-
-
def self.vapid_keys
-
config = GlobalConfig.get('VAPID_KEYS')
-
return config['VAPID_KEYS'] if config['VAPID_KEYS'].present?
-
-
# keys don't exist in the database. so let's generate and save them
-
keys = WebPush.generate_key
-
# TODO: remove the logic on environment variables when we completely deprecate
-
public_key = ENV.fetch('VAPID_PUBLIC_KEY') { keys.public_key }
-
private_key = ENV.fetch('VAPID_PRIVATE_KEY') { keys.private_key }
-
-
i = InstallationConfig.where(name: 'VAPID_KEYS').first_or_create(value: { public_key: public_key, private_key: private_key })
-
i.value
-
end
-
-
private_class_method :vapid_keys
-
end
-
class Webhooks::Trigger
-
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
-
-
def initialize(url, payload, webhook_type)
-
@url = url
-
@payload = payload
-
@webhook_type = webhook_type
-
end
-
-
def self.execute(url, payload, webhook_type)
-
new(url, payload, webhook_type).execute
-
end
-
-
def execute
-
perform_request
-
rescue StandardError => e
-
handle_error(e)
-
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}"
-
end
-
-
private
-
-
def perform_request
-
RestClient::Request.execute(
-
method: :post,
-
url: @url,
-
payload: @payload.to_json,
-
headers: { content_type: :json, accept: :json },
-
timeout: 5
-
)
-
end
-
-
def handle_error(error)
-
return unless should_handle_error?
-
return unless message
-
-
update_message_status(error)
-
end
-
-
def should_handle_error?
-
@webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
-
end
-
-
def update_message_status(error)
-
Messages::StatusUpdateService.new(message, 'failed', error.message).perform
-
end
-
-
def message
-
return if message_id.blank?
-
-
@message ||= Message.find_by(id: message_id)
-
end
-
-
def message_id
-
@payload[:id]
-
end
-
end
-
# frozen_string_literal: true
-
-
class Webhooks::Twitter
-
SUPPORTED_EVENTS = [:direct_message_events, :tweet_create_events].freeze
-
EDITABLE_ATTRS = [:tweets_enabled].freeze
-
-
attr_accessor :params, :account
-
-
def initialize(params)
-
@params = params
-
end
-
-
def consume
-
send(event_name) if event_name
-
end
-
-
private
-
-
def event_name
-
@event_name ||= SUPPORTED_EVENTS.find { |key| @params.key?(key.to_s) }
-
end
-
-
def direct_message_events
-
::Twitter::DirectMessageParserService.new(payload: @params).perform
-
end
-
-
def tweet_create_events
-
::Twitter::TweetParserService.new(payload: @params).perform
-
end
-
end